detached 1de3199

Hybrid remote lifecycle with monitor-owned directory (#262)

* feat(remote): monitor-owned directory with nonblocking startup

Serve a snapshot-only remote directory from the monitor runner: the
daemon constructor and first paint perform no remote I/O, Serve starts
the runner without waiting for registry, cache, or runtime readiness,
and stops it with its own context.

Registry polls publish only on membership, identity, rank, or cache-
seed change; identical polls stay silent. Picker rebuilds preserve an
in-flight remote preview when the exact selected route is unchanged,
and the picker shows a checking placeholder until the directory is
initialized. Palette serving-daemon suppression compares exact
endpoint identity via lifecycle match. SSH catalog errors separate
client-side transport/auth/trust failures from remote command output
using exit-255 framing, and legacy daemon-owned catalog state is
removed.

* fix(remote): address Copilot review findings on hybrid lifecycle

* fix(remote): apply senior wrap-up review follow-ups

Single shared wall-time shutdown budget for the remote watcher and monitor runner via systemClock, keeping the ports.Timer seam. Document one-shot cache seeding and the hand-edited mock copy.
commit
1de3199cc808e97276054b38970f22d61a33bcf4
author
bnema · · 2026-09-07 21:11 UTC
committer
GitHub · · 2026-09-07 21:11 UTC
parent
9cec0d8
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
52 files · +6175 -1920
M boundary_test.go +6 -0
@@ -71,6 +71,12 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) {
7171 }{
7272 {"usecase accepts semantic protocol", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol", false, true},
7373 {"usecase rejects wire", modulePath + "/internal/usecase/daemon", modulePath + "/internal/protocol/wire", false, false},
74+ {"remotes accepts ports", modulePath + "/internal/usecase/remotes", modulePath + "/internal/ports", false, true},
75+ {"remotes accepts domain", modulePath + "/internal/usecase/remotes", modulePath + "/internal/domain", false, true},
76+ {"remotes rejects wire", modulePath + "/internal/usecase/remotes", modulePath + "/internal/protocol/wire", false, false},
77+ {"remotes rejects adapter", modulePath + "/internal/usecase/remotes", modulePath + "/internal/adapters/ipc", false, false},
78+ {"remotes rejects app", modulePath + "/internal/usecase/remotes", modulePath + "/internal/app", false, false},
79+ {"remotes rejects persist", modulePath + "/internal/usecase/remotes", modulePath + "/internal/persist", false, false},
7480 {"usecase rejects adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/ipc", false, false},
7581 {"domain rejects ports", modulePath + "/internal/domain", modulePath + "/internal/ports", false, false},
7682 {"pkg rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", false, false},
M internal/adapters/remote/catalog.go +138 -6
@@ -8,6 +8,7 @@ import (
88 "fmt"
99 "io"
1010 "log/slog"
11+ "os"
1112 "os/exec"
1213 "strings"
1314 "time"
@@ -24,7 +25,8 @@ import (
2425 const (
2526 maxCatalogBytes = 1 << 20
2627 maxCatalogDiagnosticBytes = 4 << 10
27- catalogCommandTimeout = 30 * time.Second
28+ catalogCommandTimeout = 10 * time.Second
29+ catalogSSHConnectTimeout = 5 * time.Second
2830 catalogCommandWaitDelay = 500 * time.Millisecond
2931 )
3032
@@ -36,6 +38,131 @@ var (
3638 errCatalogTooLarge = errors.New("remote catalog: output exceeds size limit")
3739 )
3840
41+// sshClientTrustMarkers are OpenSSH-client-originated diagnostics for host-key
42+// trust failures. They are only consulted when the ssh client itself exited
43+// 255, which separates client-side failures from remote command output.
44+var sshClientTrustMarkers = []string{
45+ "Host key verification failed.",
46+ "REMOTE HOST IDENTIFICATION HAS CHANGED",
47+ "Host key for ",
48+ "No host key is known ",
49+ "No ECDSA host key is known ",
50+}
51+
52+// sshClientAuthMarkers are OpenSSH-client diagnostics for explicit
53+// authentication failures. Unlike host-key text, these phrases can also be
54+// emitted by a remote command that exits 255 (ssh propagates the remote
55+// status), so they only classify when ssh-client framing proves client
56+// provenance: an `ssh:` line prefix or a `host:` label prefix on the same
57+// line. Bare matches stay generic transport errors.
58+var sshClientAuthMarkers = []string{
59+ "Permission denied ",
60+ "Permission denied (",
61+ "Too many authentication failures",
62+ "Authentication failed",
63+}
64+
65+// sshClientTimeoutMarkers are OpenSSH-client diagnostics for bounded
66+// connect/command timeouts. As with authentication text, they require
67+// ssh-client framing; unframed matches stay generic transport errors.
68+var sshClientTimeoutMarkers = []string{
69+ "Connection timed out",
70+ "timed out",
71+ "Timeout, server not responding",
72+}
73+
74+// ClassifyCatalogError maps an observation failure to its sanitized failure
75+// kind without leaking raw stderr, keys, endpoints or terminal contents.
76+// Unrecognized SSH errors stay generic transport errors: authentication is
77+// never inferred from arbitrary remote stderr, only from explicit ssh-client
78+// diagnostics on ssh client exit 255.
79+func ClassifyCatalogError(err error) domain.RemoteFailureKind {
80+ if err == nil {
81+ return domain.RemoteFailureNone
82+ }
83+ if errors.Is(err, context.DeadlineExceeded) {
84+ return domain.RemoteFailureTimeout
85+ }
86+ var mismatch *catalogue.RemoteCatalogVersionMismatchError
87+ if errors.As(err, &mismatch) {
88+ return domain.RemoteFailureIncompatible
89+ }
90+ if errors.Is(err, errCatalogTooLarge) ||
91+ errors.Is(err, errCatalogDecode) ||
92+ errors.Is(err, errCatalogTrailing) ||
93+ errors.Is(err, errCatalogRequired) ||
94+ errors.Is(err, catalogue.ErrInvalidRemoteCatalog) {
95+ return domain.RemoteFailureInvalidResponse
96+ }
97+ if errors.Is(err, errCatalogSSH) && sshClientExit(err) {
98+ text := err.Error()
99+ for _, marker := range sshClientTrustMarkers {
100+ if strings.Contains(text, marker) {
101+ return domain.RemoteFailureTrust
102+ }
103+ }
104+ for _, marker := range sshClientAuthMarkers {
105+ if hasClientFramedMarker(text, marker) {
106+ return domain.RemoteFailureAuthentication
107+ }
108+ }
109+ for _, marker := range sshClientTimeoutMarkers {
110+ if hasClientFramedMarker(text, marker) {
111+ return domain.RemoteFailureTimeout
112+ }
113+ }
114+ }
115+ if timeoutError(err) {
116+ return domain.RemoteFailureTimeout
117+ }
118+ return domain.RemoteFailureTransport
119+}
120+
121+// hasClientFramedMarker reports whether marker appears with ssh-client
122+// framing: on a line starting with `ssh:`, or after a `host:` label prefix
123+// on the same line (the `destination: message` form ssh uses for its own
124+// errors). Only the stderr tail past the client's exit status is examined
125+// so the wrapper's own colons never count as framing. A remote command
126+// exiting 255 can emit the same phrases without framing, so unframed
127+// matches prove nothing and stay transport errors.
128+func hasClientFramedMarker(text, marker string) bool {
129+ diag := text
130+ if _, after, ok := strings.Cut(text, "exit status 255: "); ok {
131+ diag = after
132+ }
133+ for _, line := range strings.Split(diag, "\n") {
134+ if !strings.Contains(line, marker) {
135+ continue
136+ }
137+ if strings.HasPrefix(line, "ssh:") {
138+ return true
139+ }
140+ if label, _, ok := strings.Cut(line, ": "); ok && !strings.ContainsAny(label, " \t:") {
141+ return true
142+ }
143+ }
144+ return false
145+}
146+
147+// sshClientExit reports whether the ssh client itself failed (exit 255),
148+// separating client-side transport/auth/trust failures from remote command
149+// output carried in the same wrapped error.
150+func sshClientExit(err error) bool {
151+ var exit *exec.ExitError
152+ if !errors.As(err, &exit) {
153+ return false
154+ }
155+ return exit.ExitCode() == 255
156+}
157+
158+func timeoutError(err error) bool {
159+ var timeout interface{ Timeout() bool }
160+ if errors.As(err, &timeout) && timeout.Timeout() {
161+ return true
162+ }
163+ return errors.Is(err, os.ErrDeadlineExceeded)
164+}
165+
39166 // CatalogClient fetches remote session catalogs over SSH.
40167 type CatalogClient struct {
41168 command func(ctx context.Context, name string, args ...string) *exec.Cmd
@@ -57,10 +184,13 @@ func (c *CatalogClient) listTimeout() time.Duration {
57184 return catalogCommandTimeout
58185 }
59186
60-// List runs `ssh -- <target> 'vev' 'cmd' 'remote-catalog' '--json'` and decodes
61-// exactly one versioned catalog envelope from stdout. List always applies a
62-// bounded command timeout derived from ctx so a caller with no deadline cannot
63-// hang indefinitely, while still honoring caller cancellation.
187+// List runs a non-interactive `ssh` observation of `vev cmd remote-catalog
188+// --json` and decodes exactly one versioned catalog envelope from stdout.
189+// The observation never prompts, allocates a TTY, mutates trust, or executes
190+// host strings as shell fragments. List always applies a bounded command
191+// timeout derived from ctx so a caller with no deadline cannot hang
192+// indefinitely, while still honoring caller cancellation; owned SSH
193+// processes are killed and reaped on cancellation with a bounded wait.
64194 func (c *CatalogClient) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) {
65195 if err := ctx.Err(); err != nil {
66196 return catalogue.RemoteCatalog{}, err
@@ -77,8 +207,10 @@ func (c *CatalogClient) List(ctx context.Context, target string) (catalogue.Remo
77207 if command == nil {
78208 command = exec.CommandContext
79209 }
80- spec := sshstdio.BuildCommandForRemoteCommand(target, "vev", "cmd", "remote-catalog", "--json")
210+ spec := sshstdio.BuildCommandForObservation(target, catalogSSHConnectTimeout, "vev", "cmd", "remote-catalog", "--json")
81211 cmd := command(runCtx, spec.Path, spec.Args...)
212+ // Stdin stays detached: exec leaves a nil Stdin on the null device, and
213+ // batch mode forbids prompts even if output were ever attached to a TTY.
82214 stdout := boundedBuffer{limit: maxCatalogBytes}
83215 stderr := boundedBuffer{limit: maxCatalogDiagnosticBytes}
84216 cmd.Stdout = &stdout
M internal/adapters/remote/catalog_cache.go +29 -6
@@ -2,6 +2,7 @@ package remote
22
33 import (
44 "bytes"
5+ "encoding/hex"
56 "encoding/json"
67 "errors"
78 "fmt"
@@ -17,9 +18,10 @@ import (
1718 )
1819
1920 const (
20- catalogCacheFileName = "remote-catalog-cache.json"
21- catalogCacheExactFileVersion = 2
22- catalogCacheFileVersion = 3
21+ catalogCacheFileName = "remote-catalog-cache.json"
22+ catalogCacheExactFileVersion = 2
23+ catalogCacheLegacyFileVersion = 3
24+ catalogCacheFileVersion = 4
2325 )
2426
2527 // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir.
@@ -37,6 +39,7 @@ type catalogCacheVersion struct {
3739 type catalogCacheHost struct {
3840 Target *string `json:"target"`
3941 FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"`
42+ Incarnation *string `json:"incarnation,omitempty"`
4043 Sessions *[]catalogCacheSession `json:"sessions"`
4144 }
4245
@@ -109,8 +112,14 @@ func (c *fileCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
109112 }
110113
111114 var file catalogCacheFile
115+ requireIncarnation := false
112116 switch *version.Version {
113117 case catalogCacheFileVersion:
118+ requireIncarnation = true
119+ if err := decodeCatalogCacheFile(raw, true, &file); err != nil {
120+ return nil, err
121+ }
122+ case catalogCacheLegacyFileVersion:
114123 if err := decodeCatalogCacheFile(raw, true, &file); err != nil {
115124 return nil, err
116125 }
@@ -147,6 +156,17 @@ func (c *fileCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
147156 FetchedAt: time.Unix(0, *host.FetchedAtUnixNano),
148157 Sessions: make([]catalogue.RemoteCatalogSession, 0, len(*host.Sessions)),
149158 }
159+ if host.Incarnation != nil {
160+ raw, err := hex.DecodeString(*host.Incarnation)
161+ if err != nil || len(raw) != 16 {
162+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: invalid incarnation for %q", *host.Target)
163+ }
164+ copy(entry.Incarnation[:], raw)
165+ } else if requireIncarnation {
166+ return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing incarnation for %q", *host.Target)
167+ }
168+ // A zero incarnation is unbound advisory data: it loads but must
169+ // never seed a registration.
150170 for _, session := range *host.Sessions {
151171 if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil {
152172 return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields")
@@ -238,6 +258,7 @@ func (c *fileCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) er
238258 for _, entry := range normalized {
239259 target := entry.Host
240260 fetchedAt := entry.FetchedAt.UnixNano()
261+ incarnation := hex.EncodeToString(entry.Incarnation[:])
241262 sessions := make([]catalogCacheSession, 0, len(entry.Sessions))
242263 for _, session := range entry.Sessions {
243264 name := session.Name
@@ -261,6 +282,7 @@ func (c *fileCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) er
261282 hosts = append(hosts, catalogCacheHost{
262283 Target: &target,
263284 FetchedAtUnixNano: &fetchedAt,
285+ Incarnation: &incarnation,
264286 Sessions: &sessions,
265287 })
266288 }
@@ -291,9 +313,10 @@ func normalizeCatalogCacheEntries(entries []catalogue.RemoteCatalogCacheEntry) (
291313 hosts[entry.Host] = struct{}{}
292314
293315 copyEntry := catalogue.RemoteCatalogCacheEntry{
294- Host: entry.Host,
295- FetchedAt: entry.FetchedAt,
296- Sessions: make([]catalogue.RemoteCatalogSession, 0, len(entry.Sessions)),
316+ Host: entry.Host,
317+ FetchedAt: entry.FetchedAt,
318+ Incarnation: entry.Incarnation,
319+ Sessions: make([]catalogue.RemoteCatalogSession, 0, len(entry.Sessions)),
297320 }
298321 sessions := make(map[string]struct{}, len(entry.Sessions))
299322 for _, session := range entry.Sessions {
M internal/adapters/remote/catalog_cache_test.go +45 -14
@@ -29,8 +29,9 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
2929 fetchedAt := time.Unix(0, 1780000000000000000)
3030 entries := []catalogue.RemoteCatalogCacheEntry{
3131 {
32- Host: "zebra",
33- FetchedAt: fetchedAt,
32+ Host: "zebra",
33+ FetchedAt: fetchedAt,
34+ Incarnation: [16]byte{9},
3435 Sessions: []catalogue.RemoteCatalogSession{
3536 {
3637 LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2",
@@ -40,9 +41,10 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
4041 },
4142 },
4243 {
43- Host: "arch",
44- FetchedAt: fetchedAt.Add(time.Second),
45- Sessions: []catalogue.RemoteCatalogSession{},
44+ Host: "arch",
45+ FetchedAt: fetchedAt.Add(time.Second),
46+ Incarnation: [16]byte{7},
47+ Sessions: []catalogue.RemoteCatalogSession{},
4648 },
4749 }
4850
@@ -63,8 +65,8 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
6365 if alpha < 0 || work < 0 || alpha >= work {
6466 t.Fatalf("stored sessions are not ordered: %s", raw)
6567 }
66- if !strings.Contains(string(raw), `"version":3`) || !strings.Contains(string(raw), `"tabs":[{"id":"work-1"`) {
67- t.Fatalf("stored cache does not pin the v3 typed-tab contract: %s", raw)
68+ if !strings.Contains(string(raw), `"version":4`) || !strings.Contains(string(raw), `"tabs":[{"id":"work-1"`) {
69+ t.Fatalf("stored cache does not pin the v4 typed-tab contract: %s", raw)
6870 }
6971 if strings.Contains(string(raw), `"detail"`) || strings.Contains(string(raw), `"attention"`) {
7072 t.Fatalf("stored cache contains dynamic tab fields: %s", raw)
@@ -75,13 +77,15 @@ func TestCatalogCacheStoreLoad(t *testing.T) {
7577 }
7678 want := []catalogue.RemoteCatalogCacheEntry{
7779 {
78- Host: "arch",
79- FetchedAt: fetchedAt.Add(time.Second),
80- Sessions: []catalogue.RemoteCatalogSession{},
80+ Host: "arch",
81+ FetchedAt: fetchedAt.Add(time.Second),
82+ Incarnation: [16]byte{7},
83+ Sessions: []catalogue.RemoteCatalogSession{},
8184 },
8285 {
83- Host: "zebra",
84- FetchedAt: fetchedAt,
86+ Host: "zebra",
87+ FetchedAt: fetchedAt,
88+ Incarnation: [16]byte{9},
8589 Sessions: []catalogue.RemoteCatalogSession{
8690 {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true},
8791 {LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2", Tabs: []catalogue.RemoteCatalogTab{{ID: "work-1"}, {ID: "work-2", Index: 1}}},
@@ -156,6 +160,30 @@ func TestCatalogCacheLoadMigratesExactV2TabList(t *testing.T) {
156160 }
157161 }
158162
163+func TestCatalogCacheLegacyV3LoadsWithoutIncarnation(t *testing.T) {
164+ t.Parallel()
165+
166+ path := filepath.Join(t.TempDir(), "vev", "remote-catalog-cache.json")
167+ if err := safedir.EnsurePrivate(filepath.Dir(path)); err != nil {
168+ t.Fatalf("mkdir: %v", err)
169+ }
170+ raw := []byte(`{"version":3,"hosts":[{"target":"user@arch","fetched_at_unix_nano":1780000000000000000,"sessions":[]}]}`)
171+ if err := os.WriteFile(path, raw, 0o600); err != nil {
172+ t.Fatalf("write cache: %v", err)
173+ }
174+
175+ got, err := NewFileCatalogCache(path).Load()
176+ if err != nil {
177+ t.Fatalf("Load() error = %v", err)
178+ }
179+ if len(got) != 1 || got[0].Host != "user@arch" {
180+ t.Fatalf("Load() = %#v, want one user@arch entry", got)
181+ }
182+ if got[0].Incarnation != [16]byte{} {
183+ t.Fatalf("legacy entry incarnation = %x, want zero (unbound advisory data)", got[0].Incarnation)
184+ }
185+}
186+
159187 func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
160188 t.Parallel()
161189
@@ -166,7 +194,10 @@ func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) {
166194 {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)},
167195 {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)},
168196 {name: "obsolete count-only version", raw: []byte(`{"version":2,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[{"lifecycle_id":"01000000000000000000000000000000","name":"work","state":"up","ephemeral":false,"tabs":1,"active_tab_id":"t_work","attached":false}]}]}`)},
169- {name: "unknown version", raw: []byte(`{"version":4,"hosts":[]}`)},
197+ {name: "unknown version", raw: []byte(`{"version":5,"hosts":[]}`)},
198+ {name: "v4 missing incarnation", raw: []byte(`{"version":4,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":[]}]}`)},
199+ {name: "v4 malformed incarnation", raw: []byte(`{"version":4,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"incarnation":"not-hex","sessions":[]}]}`)},
200+ {name: "v4 short incarnation", raw: []byte(`{"version":4,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"incarnation":"0123","sessions":[]}]}`)},
170201 {name: "missing hosts", raw: []byte(`{"version":3}`)},
171202 {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)},
172203 {name: "null sessions", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)},
@@ -321,5 +352,5 @@ func TestCatalogCacheStoreRenameFailureCleansTemporaryFile(t *testing.T) {
321352 }
322353
323354 func equalRemoteCatalogCacheEntry(a, b catalogue.RemoteCatalogCacheEntry) bool {
324- return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && reflect.DeepEqual(a.Sessions, b.Sessions)
355+ return a.Host == b.Host && a.FetchedAt.Equal(b.FetchedAt) && a.Incarnation == b.Incarnation && reflect.DeepEqual(a.Sessions, b.Sessions)
325356 }
M internal/adapters/remote/catalog_test.go +73 -5
@@ -12,6 +12,7 @@ import (
1212 "time"
1313
1414 "github.com/bnema/vev/internal/adapters/sshstdio"
15+ "github.com/bnema/vev/internal/domain"
1516 "github.com/bnema/vev/internal/protocol"
1617 "github.com/bnema/vev/internal/protocol/catalogue"
1718 )
@@ -49,7 +50,7 @@ func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
4950 t.Fatalf("List() error = %v", err)
5051 }
5152
52- want := sshstdio.BuildCommandForRemoteCommand(tt.target, "vev", "cmd", "remote-catalog", "--json")
53+ want := sshstdio.BuildCommandForObservation(tt.target, catalogSSHConnectTimeout, "vev", "cmd", "remote-catalog", "--json")
5354 if gotPath != want.Path {
5455 t.Fatalf("Path = %q, want %q", gotPath, want.Path)
5556 }
@@ -61,11 +62,24 @@ func TestRemoteCatalogClientCommandConstruction(t *testing.T) {
6162 t.Fatalf("Args[%d] = %q, want %q (all %q)", i, gotArgs[i], want.Args[i], gotArgs)
6263 }
6364 }
64- if gotArgs[0] != "--" || gotArgs[1] != tt.target {
65- t.Fatalf("target must remain one unquoted local argv word after --; got %q", gotArgs)
65+ sep := -1
66+ for i, arg := range gotArgs {
67+ if arg == "--" {
68+ sep = i
69+ break
70+ }
71+ }
72+ if sep < 0 || sep+1 >= len(gotArgs) || gotArgs[sep+1] != tt.target {
73+ t.Fatalf("observation must keep the target one unquoted local argv word right after --; got %q", gotArgs)
74+ }
75+ joined := strings.Join(gotArgs[:sep], " ")
76+ for _, flag := range []string{"-T", "BatchMode=yes", "StrictHostKeyChecking=yes", "UpdateHostKeys=no", "ConnectTimeout=", "ConnectionAttempts=1"} {
77+ if !strings.Contains(joined, flag) {
78+ t.Fatalf("observation argv missing %q; got %q", flag, gotArgs)
79+ }
6680 }
67- if !strings.Contains(gotArgs[2], "'vev'") || !strings.Contains(gotArgs[2], "'remote-catalog'") {
68- t.Fatalf("remote command words must be shell-quoted; got %q", gotArgs[2])
81+ if sep+2 >= len(gotArgs) || !strings.Contains(gotArgs[sep+2], "'vev'") || !strings.Contains(gotArgs[sep+2], "'remote-catalog'") {
82+ t.Fatalf("remote command words must be shell-quoted; got %q", gotArgs)
6983 }
7084 })
7185 }
@@ -273,6 +287,60 @@ func TestRemoteCatalogClientDecode(t *testing.T) {
273287 }
274288 }
275289
290+func exitError(t *testing.T, code int) error {
291+ t.Helper()
292+ err := exec.Command("sh", "-c", fmt.Sprintf("exit %d", code)).Run()
293+ if err == nil {
294+ t.Fatalf("exit %d returned nil error", code)
295+ }
296+ return err
297+}
298+
299+func TestClassifyCatalogError(t *testing.T) {
300+ t.Parallel()
301+
302+ sshWrapped := func(code int, stderr string) error {
303+ return fmt.Errorf("%w: %w: %s", errCatalogSSH, exitError(t, code), stderr)
304+ }
305+ mismatch := &catalogue.RemoteCatalogVersionMismatchError{Got: 1, Want: 2, Kind: "protocol"}
306+
307+ tests := []struct {
308+ name string
309+ err error
310+ want domain.RemoteFailureKind
311+ }{
312+ {name: "nil", err: nil, want: domain.RemoteFailureNone},
313+ {name: "deadline", err: context.DeadlineExceeded, want: domain.RemoteFailureTimeout},
314+ {name: "canceled stays transport", err: context.Canceled, want: domain.RemoteFailureTransport},
315+ {name: "version mismatch", err: mismatch, want: domain.RemoteFailureIncompatible},
316+ {name: "wrapped version mismatch", err: fmt.Errorf("decode: %w", mismatch), want: domain.RemoteFailureIncompatible},
317+ {name: "too large", err: errCatalogTooLarge, want: domain.RemoteFailureInvalidResponse},
318+ {name: "decode", err: fmt.Errorf("%w: bad", errCatalogDecode), want: domain.RemoteFailureInvalidResponse},
319+ {name: "trailing", err: errCatalogTrailing, want: domain.RemoteFailureInvalidResponse},
320+ {name: "required", err: errCatalogRequired, want: domain.RemoteFailureInvalidResponse},
321+ {name: "invalid catalog", err: fmt.Errorf("%w: bad session", catalogue.ErrInvalidRemoteCatalog), want: domain.RemoteFailureInvalidResponse},
322+ {name: "trust failure", err: sshWrapped(255, "Host key verification failed."), want: domain.RemoteFailureTrust},
323+ {name: "changed host key", err: sshWrapped(255, "@@@@@@@@@@@@\nWARNING: REMOTE HOST IDENTIFICATION HAS CHANGED!\n@@@@@@@@@@@@"), want: domain.RemoteFailureTrust},
324+ {name: "auth failure", err: sshWrapped(255, "user@arch: Permission denied (publickey)."), want: domain.RemoteFailureAuthentication},
325+ {name: "bare too many auth failures stay transport", err: sshWrapped(255, "Too many authentication failures"), want: domain.RemoteFailureTransport},
326+ {name: "remote command exits 255 with auth text stays transport", err: sshWrapped(255, "vev: remote helper failed\nPermission denied (publickey)"), want: domain.RemoteFailureTransport},
327+ {name: "remote command timeout text stays transport", err: sshWrapped(255, "catalog fetch timed out"), want: domain.RemoteFailureTransport},
328+ {name: "connect timeout", err: sshWrapped(255, "ssh: connect to host arch port 22: Connection timed out"), want: domain.RemoteFailureTimeout},
329+ {name: "unrecognized ssh failure stays transport", err: sshWrapped(255, "some new mysterious error"), want: domain.RemoteFailureTransport},
330+ {name: "remote command failure stays transport", err: sshWrapped(1, "remote vev exploded"), want: domain.RemoteFailureTransport},
331+ {name: "auth text without client exit stays transport", err: fmt.Errorf("%w: %s", errCatalogSSH, "Permission denied (publickey)"), want: domain.RemoteFailureTransport},
332+ {name: "plain error stays transport", err: errors.New("boom"), want: domain.RemoteFailureTransport},
333+ }
334+ for _, tt := range tests {
335+ t.Run(tt.name, func(t *testing.T) {
336+ t.Parallel()
337+ if got := ClassifyCatalogError(tt.err); got != tt.want {
338+ t.Fatalf("ClassifyCatalogError(%v) = %v, want %v", tt.err, got, tt.want)
339+ }
340+ })
341+ }
342+}
343+
276344 func TestRemoteCatalogClientContextCanceled(t *testing.T) {
277345 t.Parallel()
278346 ctx, cancel := context.WithCancel(context.Background())
M internal/adapters/remote/host_registry.go +271 -78
@@ -1,13 +1,14 @@
11 package remote
22
33 import (
4+ "crypto/rand"
5+ "encoding/hex"
46 "encoding/json"
57 "errors"
68 "fmt"
79 "log/slog"
810 "os"
911 "path/filepath"
10- "slices"
1112 "sort"
1213 "syscall"
1314 "unicode/utf8"
@@ -18,27 +19,56 @@ import (
1819 )
1920
2021 const (
21- hostsFileName = "hosts.json"
22- hostsFileVersion = 2
22+ hostsFileName = "hosts.json"
23+ hostsFileVersion = 3
24+ hostsBackupSuffix = ".v2.bak"
25+ legacyHostsVersion = 2
26+ legacyListVersion = 1
2327 )
2428
29+// newRemoteRegistration creates a registration with a fresh random
30+// incarnation for a validated endpoint. Domain construction stays
31+// deterministic; only this adapter samples randomness.
32+func newRemoteRegistration(endpoint string) (domain.RemoteRegistration, error) {
33+ var incarnation [16]byte
34+ if _, err := rand.Read(incarnation[:]); err != nil {
35+ return domain.RemoteRegistration{}, fmt.Errorf("remote registration: %w", err)
36+ }
37+ return domain.NewRemoteRegistration(endpoint, incarnation)
38+}
39+
2540 // HostStorePath returns the canonical location of the remote host store in stateDir.
2641 func HostStorePath(stateDir string) string { return filepath.Join(stateDir, hostsFileName) }
2742
2843 type hostsFile struct {
44+ Version int `json:"version"`
45+ Pinned []hostRecord `json:"pinned"`
46+ Learned []hostRecord `json:"learned"`
47+}
48+
49+// hostRecord is the persisted registration for one endpoint: the exact
50+// configured identity plus a random incarnation and fencing generation.
51+// Incarnation is hex-encoded to stay debuggable in the JSON file.
52+type hostRecord struct {
53+ Endpoint string `json:"endpoint"`
54+ Incarnation string `json:"incarnation"`
55+ Generation uint64 `json:"generation"`
56+}
57+
58+type legacyHostsFile struct {
2959 Version int `json:"version"`
3060 Pinned []string `json:"pinned"`
3161 Learned []string `json:"learned"`
3262 }
3363
34-type legacyHostsFile struct {
64+type legacyListHostsFile struct {
3565 Version int `json:"version"`
3666 Hosts []string `json:"hosts"`
3767 }
3868
3969 type hostState struct {
40- pinned []string
41- learned map[string]struct{}
70+ pinned []domain.RemoteRegistration
71+ learned map[string]domain.RemoteRegistration
4272 }
4373
4474 type fileHostStore struct {
@@ -52,14 +82,14 @@ func NewFileHostStore(path string) ports.RemoteHostStore {
5282
5383 var _ ports.RemoteHostStore = (*fileHostStore)(nil)
5484
55-func (s *fileHostStore) Hosts() (pinned, learned []string, err error) {
85+func (s *fileHostStore) Hosts() (pinned, learned []domain.RemoteRegistration, err error) {
5686 err = s.withLock(func() error {
57- state, loadErr := s.loadLocked()
87+ state, loadErr := s.loadMigratedLocked()
5888 if loadErr != nil {
5989 return loadErr
6090 }
61- pinned = append([]string(nil), state.pinned...)
62- learned = sortedHosts(state.learned)
91+ pinned = append([]domain.RemoteRegistration(nil), state.pinned...)
92+ learned = sortedRegistrations(state.learned)
6393 return nil
6494 })
6595 if err != nil {
@@ -74,14 +104,25 @@ func (s *fileHostStore) AddPinned(target string) error {
74104 return err
75105 }
76106 err := s.withLock(func() error {
77- state, err := s.loadLocked()
107+ state, err := s.loadMigratedLocked()
78108 if err != nil {
79109 return err
80110 }
81- if slices.Contains(state.pinned, target) {
82- return nil
111+ for _, record := range state.pinned {
112+ if record.Endpoint == target {
113+ return nil
114+ }
115+ }
116+ // Pinning an existing registration retains its incarnation.
117+ if record, ok := state.learned[target]; ok {
118+ state.pinned = append(state.pinned, record)
119+ } else {
120+ record, err := newRemoteRegistration(target)
121+ if err != nil {
122+ return err
123+ }
124+ state.pinned = append(state.pinned, record)
83125 }
84- state.pinned = append(state.pinned, target)
85126 return s.saveLocked(state)
86127 })
87128 if err != nil {
@@ -95,11 +136,11 @@ func (s *fileHostStore) RemovePinned(target string) error {
95136 return err
96137 }
97138 err := s.withLock(func() error {
98- state, err := s.loadLocked()
139+ state, err := s.loadMigratedLocked()
99140 if err != nil {
100141 return err
101142 }
102- next, changed := removeTarget(state.pinned, target)
143+ next, changed := removeRegistration(state.pinned, target)
103144 if !changed {
104145 return nil
105146 }
@@ -117,14 +158,23 @@ func (s *fileHostStore) Remember(target string) error {
117158 return err
118159 }
119160 err := s.withLock(func() error {
120- state, err := s.loadLocked()
161+ state, err := s.loadMigratedLocked()
121162 if err != nil {
122163 return err
123164 }
124165 if _, ok := state.learned[target]; ok {
125166 return nil
126167 }
127- state.learned[target] = struct{}{}
168+ // Remembering an existing registration retains its incarnation.
169+ if record, ok := findRegistration(state.pinned, target); ok {
170+ state.learned[target] = record
171+ } else {
172+ record, err := newRemoteRegistration(target)
173+ if err != nil {
174+ return err
175+ }
176+ state.learned[target] = record
177+ }
128178 return s.saveLocked(state)
129179 })
130180 if err != nil {
@@ -138,7 +188,7 @@ func (s *fileHostStore) Forget(target string) error {
138188 return err
139189 }
140190 err := s.withLock(func() error {
141- state, err := s.loadLocked()
191+ state, err := s.loadMigratedLocked()
142192 if err != nil {
143193 return err
144194 }
@@ -159,15 +209,17 @@ func (s *fileHostStore) Remove(target string) (deleted bool, err error) {
159209 return false, err
160210 }
161211 err = s.withLock(func() error {
162- state, err := s.loadLocked()
212+ state, err := s.loadMigratedLocked()
163213 if err != nil {
164214 return err
165215 }
166- nextPinned, pinnedChanged := removeTarget(state.pinned, target)
216+ nextPinned, pinnedChanged := removeRegistration(state.pinned, target)
167217 _, learnedPresent := state.learned[target]
168218 if !pinnedChanged && !learnedPresent {
169219 return nil
170220 }
221+ // Complete removal drops the incarnation: a later re-add creates
222+ // a new registration even though the endpoint string matches.
171223 state.pinned = nextPinned
172224 delete(state.learned, target)
173225 if err := s.saveLocked(state); err != nil {
@@ -202,12 +254,17 @@ func (s *fileHostStore) withLock(fn func() error) error {
202254 return fn()
203255 }
204256
205-func (s *fileHostStore) loadLocked() (hostState, error) {
257+// loadMigratedLocked loads the registry, migrating version 1 list files and
258+// version 2 string lists to version 3 registration records. Migration assigns
259+// one fresh random incarnation per endpoint, preserves pinned/learned
260+// ordering and membership, and persists a private pre-migration backup.
261+// Unsupported or corrupt versions fail closed without replacement.
262+func (s *fileHostStore) loadMigratedLocked() (hostState, error) {
206263 raw, err := os.ReadFile(s.path)
207264 if errors.Is(err, os.ErrNotExist) {
208265 return hostState{
209- pinned: make([]string, 0),
210- learned: make(map[string]struct{}),
266+ pinned: make([]domain.RemoteRegistration, 0),
267+ learned: make(map[string]domain.RemoteRegistration),
211268 }, nil
212269 }
213270 if err != nil {
@@ -224,52 +281,152 @@ func (s *fileHostStore) loadLocked() (hostState, error) {
224281 }
225282
226283 switch header.Version {
227- case 1:
228- var legacy legacyHostsFile
284+ case hostsFileVersion:
285+ return decodeHostsV3(raw)
286+ case legacyHostsVersion, legacyListVersion:
287+ state, err := decodeLegacyHosts(raw, header.Version)
288+ if err != nil {
289+ return hostState{}, err
290+ }
291+ if err := s.backupAndSaveMigratedLocked(raw, state); err != nil {
292+ return hostState{}, err
293+ }
294+ return state, nil
295+ default:
296+ return hostState{}, fmt.Errorf("remote host store: unsupported hosts file version %d", header.Version)
297+ }
298+}
299+
300+// backupAndSaveMigratedLocked preserves the pre-migration bytes privately and
301+// atomically replaces the registry with version 3 records. Both steps run
302+// under the existing registry lock.
303+func (s *fileHostStore) backupAndSaveMigratedLocked(raw []byte, state hostState) error {
304+ if err := atomicReplacePrivateFile(s.path+hostsBackupSuffix, raw); err != nil {
305+ return err
306+ }
307+ return s.saveLocked(state)
308+}
309+
310+func decodeHostsV3(raw []byte) (hostState, error) {
311+ var decoded hostsFile
312+ if err := json.Unmarshal(raw, &decoded); err != nil {
313+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
314+ }
315+ if decoded.Pinned == nil {
316+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing pinned")
317+ }
318+ if decoded.Learned == nil {
319+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing learned")
320+ }
321+ pinned, err := validatePinnedRecords(decoded.Pinned)
322+ if err != nil {
323+ return hostState{}, err
324+ }
325+ learned, err := validateLearnedRecords(decoded.Learned)
326+ if err != nil {
327+ return hostState{}, err
328+ }
329+ for _, record := range pinned {
330+ if other, ok := learned[record.Endpoint]; ok && !other.Equal(record) {
331+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: conflicting identities for %q", record.Endpoint)
332+ }
333+ }
334+ return hostState{pinned: pinned, learned: learned}, nil
335+}
336+
337+func decodeLegacyHosts(raw []byte, version int) (hostState, error) {
338+ if version == legacyListVersion {
339+ var legacy legacyListHostsFile
229340 if err := json.Unmarshal(raw, &legacy); err != nil {
230341 return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
231342 }
232343 if legacy.Hosts == nil {
233344 return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing hosts")
234345 }
235- learned, err := validateLearnedHosts(legacy.Hosts)
346+ learned, err := migrateTargetsToRecords(legacy.Hosts, nil)
236347 if err != nil {
237348 return hostState{}, err
238349 }
239- return hostState{pinned: []string{}, learned: learned}, nil
240- case hostsFileVersion:
241- var decoded hostsFile
242- if err := json.Unmarshal(raw, &decoded); err != nil {
243- return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
244- }
245- if decoded.Pinned == nil {
246- return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing pinned")
350+ return hostState{pinned: []domain.RemoteRegistration{}, learned: learned}, nil
351+ }
352+ var legacy legacyHostsFile
353+ if err := json.Unmarshal(raw, &legacy); err != nil {
354+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
355+ }
356+ if legacy.Pinned == nil {
357+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing pinned")
358+ }
359+ if legacy.Learned == nil {
360+ return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing learned")
361+ }
362+ pinned, err := migrateOrderedTargetsToRecords(legacy.Pinned)
363+ if err != nil {
364+ return hostState{}, err
365+ }
366+ // One endpoint keeps one registration across memberships: learned
367+ // entries reuse the pinned incarnation instead of minting a second
368+ // identity that would surface on RemovePinned.
369+ reuse := make(map[string]domain.RemoteRegistration, len(pinned))
370+ for _, record := range pinned {
371+ reuse[record.Endpoint] = record
372+ }
373+ learned, err := migrateTargetsToRecords(legacy.Learned, reuse)
374+ if err != nil {
375+ return hostState{}, err
376+ }
377+ return hostState{pinned: pinned, learned: learned}, nil
378+}
379+
380+func migrateOrderedTargetsToRecords(hosts []string) ([]domain.RemoteRegistration, error) {
381+ pinned := make([]domain.RemoteRegistration, 0, len(hosts))
382+ seen := make(map[string]struct{}, len(hosts))
383+ for _, host := range hosts {
384+ if err := validateHostTarget(host); err != nil {
385+ return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err)
247386 }
248- if decoded.Learned == nil {
249- return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing learned")
387+ if _, dup := seen[host]; dup {
388+ return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate pinned host %q", host)
250389 }
251- pinned, err := validatePinnedHosts(decoded.Pinned)
390+ seen[host] = struct{}{}
391+ record, err := newRemoteRegistration(host)
252392 if err != nil {
253- return hostState{}, err
393+ return nil, err
254394 }
255- learned, err := validateLearnedHosts(decoded.Learned)
395+ pinned = append(pinned, record)
396+ }
397+ return pinned, nil
398+}
399+
400+func migrateTargetsToRecords(hosts []string, reuse map[string]domain.RemoteRegistration) (map[string]domain.RemoteRegistration, error) {
401+ learned := make(map[string]domain.RemoteRegistration, len(hosts))
402+ for _, host := range hosts {
403+ if err := validateHostTarget(host); err != nil {
404+ return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err)
405+ }
406+ if _, dup := learned[host]; dup {
407+ return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate learned host %q", host)
408+ }
409+ if record, ok := reuse[host]; ok {
410+ learned[host] = record
411+ continue
412+ }
413+ record, err := newRemoteRegistration(host)
256414 if err != nil {
257- return hostState{}, err
415+ return nil, err
258416 }
259- return hostState{pinned: pinned, learned: learned}, nil
260- default:
261- return hostState{}, fmt.Errorf("remote host store: unsupported hosts file version %d", header.Version)
417+ learned[host] = record
262418 }
419+ return learned, nil
263420 }
264421
265422 func (s *fileHostStore) saveLocked(state hostState) error {
266- pinned := append([]string{}, state.pinned...)
267- if pinned == nil {
268- pinned = []string{}
423+ pinned := make([]hostRecord, 0, len(state.pinned))
424+ for _, record := range state.pinned {
425+ pinned = append(pinned, encodeRecord(record))
269426 }
270- learned := sortedHosts(state.learned)
271- if learned == nil {
272- learned = []string{}
427+ learned := make([]hostRecord, 0, len(state.learned))
428+ for _, record := range sortedRegistrations(state.learned) {
429+ learned = append(learned, encodeRecord(record))
273430 }
274431 payload, err := json.Marshal(hostsFile{
275432 Version: hostsFileVersion,
@@ -282,6 +439,31 @@ func (s *fileHostStore) saveLocked(state hostState) error {
282439 return atomicReplacePrivateFile(s.path, append(payload, '\n'))
283440 }
284441
442+func encodeRecord(record domain.RemoteRegistration) hostRecord {
443+ return hostRecord{
444+ Endpoint: record.Endpoint,
445+ Incarnation: hex.EncodeToString(record.Incarnation[:]),
446+ Generation: uint64(record.Generation),
447+ }
448+}
449+
450+func decodeRecord(record hostRecord) (domain.RemoteRegistration, error) {
451+ if err := validateHostTarget(record.Endpoint); err != nil {
452+ return domain.RemoteRegistration{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
453+ }
454+ raw, err := hex.DecodeString(record.Incarnation)
455+ if err != nil || len(raw) != 16 {
456+ return domain.RemoteRegistration{}, fmt.Errorf("remote host store: malformed hosts file: invalid incarnation for %q", record.Endpoint)
457+ }
458+ var incarnation [16]byte
459+ copy(incarnation[:], raw)
460+ registration := domain.RemoteRegistration{Endpoint: record.Endpoint, Incarnation: incarnation, Generation: domain.RemoteGeneration(record.Generation)}
461+ if err := registration.Validate(); err != nil {
462+ return domain.RemoteRegistration{}, fmt.Errorf("remote host store: malformed hosts file: %w", err)
463+ }
464+ return registration, nil
465+}
466+
285467 func atomicReplacePrivateFile(path string, payload []byte) error {
286468 dir := filepath.Dir(path)
287469 if err := safedir.EnsurePrivate(dir); err != nil {
@@ -318,32 +500,34 @@ func atomicReplacePrivateFile(path string, payload []byte) error {
318500 return syncDir(dir)
319501 }
320502
321-func validatePinnedHosts(hosts []string) ([]string, error) {
322- pinned := make([]string, 0, len(hosts))
323- seen := make(map[string]struct{}, len(hosts))
324- for _, host := range hosts {
325- if err := validateHostTarget(host); err != nil {
326- return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err)
503+func validatePinnedRecords(records []hostRecord) ([]domain.RemoteRegistration, error) {
504+ pinned := make([]domain.RemoteRegistration, 0, len(records))
505+ seen := make(map[string]struct{}, len(records))
506+ for _, record := range records {
507+ registration, err := decodeRecord(record)
508+ if err != nil {
509+ return nil, err
327510 }
328- if _, dup := seen[host]; dup {
329- return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate pinned host %q", host)
511+ if _, dup := seen[registration.Endpoint]; dup {
512+ return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate pinned host %q", registration.Endpoint)
330513 }
331- seen[host] = struct{}{}
332- pinned = append(pinned, host)
514+ seen[registration.Endpoint] = struct{}{}
515+ pinned = append(pinned, registration)
333516 }
334517 return pinned, nil
335518 }
336519
337-func validateLearnedHosts(hosts []string) (map[string]struct{}, error) {
338- learned := make(map[string]struct{}, len(hosts))
339- for _, host := range hosts {
340- if err := validateHostTarget(host); err != nil {
341- return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err)
520+func validateLearnedRecords(records []hostRecord) (map[string]domain.RemoteRegistration, error) {
521+ learned := make(map[string]domain.RemoteRegistration, len(records))
522+ for _, record := range records {
523+ registration, err := decodeRecord(record)
524+ if err != nil {
525+ return nil, err
342526 }
343- if _, dup := learned[host]; dup {
344- return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate learned host %q", host)
527+ if _, dup := learned[registration.Endpoint]; dup {
528+ return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate learned host %q", registration.Endpoint)
345529 }
346- learned[host] = struct{}{}
530+ learned[registration.Endpoint] = registration
347531 }
348532 return learned, nil
349533 }
@@ -355,25 +539,34 @@ func validateHostTarget(target string) error {
355539 return domain.ValidateRemoteHostTarget(target)
356540 }
357541
358-func removeTarget(hosts []string, target string) ([]string, bool) {
359- out := make([]string, 0, len(hosts))
542+func findRegistration(registrations []domain.RemoteRegistration, target string) (domain.RemoteRegistration, bool) {
543+ for _, record := range registrations {
544+ if record.Endpoint == target {
545+ return record, true
546+ }
547+ }
548+ return domain.RemoteRegistration{}, false
549+}
550+
551+func removeRegistration(registrations []domain.RemoteRegistration, target string) ([]domain.RemoteRegistration, bool) {
552+ out := make([]domain.RemoteRegistration, 0, len(registrations))
360553 changed := false
361- for _, host := range hosts {
362- if host == target {
554+ for _, record := range registrations {
555+ if record.Endpoint == target {
363556 changed = true
364557 continue
365558 }
366- out = append(out, host)
559+ out = append(out, record)
367560 }
368561 return out, changed
369562 }
370563
371-func sortedHosts(hosts map[string]struct{}) []string {
372- out := make([]string, 0, len(hosts))
373- for host := range hosts {
374- out = append(out, host)
564+func sortedRegistrations(registrations map[string]domain.RemoteRegistration) []domain.RemoteRegistration {
565+ out := make([]domain.RemoteRegistration, 0, len(registrations))
566+ for _, record := range registrations {
567+ out = append(out, record)
375568 }
376- sort.Strings(out)
569+ sort.Slice(out, func(i, j int) bool { return out[i].Endpoint < out[j].Endpoint })
377570 return out
378571 }
379572
M internal/adapters/remote/host_registry_test.go +269 -37
@@ -11,6 +11,8 @@ import (
1111 "strings"
1212 "sync"
1313 "testing"
14+
15+ "github.com/bnema/vev/internal/domain"
1416 )
1517
1618 func TestHostStorePath(t *testing.T) {
@@ -21,6 +23,23 @@ func TestHostStorePath(t *testing.T) {
2123 }
2224 }
2325
26+func TestNewRemoteRegistrationSamplesUniqueIncarnations(t *testing.T) {
27+ first, err := newRemoteRegistration("user@arch")
28+ if err != nil {
29+ t.Fatalf("newRemoteRegistration: %v", err)
30+ }
31+ second, err := newRemoteRegistration("user@arch")
32+ if err != nil {
33+ t.Fatalf("newRemoteRegistration: %v", err)
34+ }
35+ if first.Incarnation == second.Incarnation {
36+ t.Fatal("incarnations must be unique per registration")
37+ }
38+ if _, err := newRemoteRegistration("not a host"); err == nil {
39+ t.Fatal("invalid endpoint must be rejected")
40+ }
41+}
42+
2443 func TestHostStoreCrossProcessHelper(t *testing.T) {
2544 if os.Getenv("VEV_HOST_STORE_HELPER") != "1" {
2645 t.Skip("helper process")
@@ -84,11 +103,28 @@ func TestHostStoreCrossProcessConcurrentRemember(t *testing.T) {
84103 }
85104 want := append(append([]string{}, left...), right...)
86105 sort.Strings(want)
87- if !slices.Equal(learned, want) {
106+ if !slices.Equal(registrationEndpoints(learned), want) {
88107 t.Fatalf("learned len=%d want %d; missing merges under cross-process writers", len(learned), len(want))
89108 }
90109 }
91110
111+func registrationEndpoints(registrations []domain.RemoteRegistration) []string {
112+ endpoints := make([]string, 0, len(registrations))
113+ for _, record := range registrations {
114+ endpoints = append(endpoints, record.Endpoint)
115+ }
116+ return endpoints
117+}
118+
119+func requireValidRegistrations(t *testing.T, registrations []domain.RemoteRegistration) {
120+ t.Helper()
121+ for _, record := range registrations {
122+ if err := record.Validate(); err != nil {
123+ t.Fatalf("registration %+v invalid: %v", record, err)
124+ }
125+ }
126+}
127+
92128 func TestHostStore(t *testing.T) {
93129 t.Parallel()
94130
@@ -146,12 +182,14 @@ func TestHostStore(t *testing.T) {
146182 }
147183 wantPinned := []string{"zebra", "arch"}
148184 wantLearned := []string{"beta", "mule"}
149- if !slices.Equal(pinned, wantPinned) {
185+ if !slices.Equal(registrationEndpoints(pinned), wantPinned) {
150186 t.Fatalf("pinned = %v, want %v", pinned, wantPinned)
151187 }
152- if !slices.Equal(learned, wantLearned) {
188+ if !slices.Equal(registrationEndpoints(learned), wantLearned) {
153189 t.Fatalf("learned = %v, want %v", learned, wantLearned)
154190 }
191+ requireValidRegistrations(t, pinned)
192+ requireValidRegistrations(t, learned)
155193
156194 info, err := os.Stat(path)
157195 if err != nil {
@@ -176,15 +214,31 @@ func TestHostStore(t *testing.T) {
176214 if err := json.Unmarshal(raw, &decoded); err != nil {
177215 t.Fatalf("decode store: %v", err)
178216 }
179- if decoded.Version != 2 {
180- t.Fatalf("version = %d, want 2", decoded.Version)
217+ if decoded.Version != 3 {
218+ t.Fatalf("version = %d, want 3", decoded.Version)
219+ }
220+ stored := make([]domain.RemoteRegistration, 0, len(decoded.Pinned)+len(decoded.Learned))
221+ for _, record := range decoded.Pinned {
222+ registration, err := decodeRecord(record)
223+ if err != nil {
224+ t.Fatalf("decode stored pinned: %v", err)
225+ }
226+ stored = append(stored, registration)
227+ }
228+ for _, record := range decoded.Learned {
229+ registration, err := decodeRecord(record)
230+ if err != nil {
231+ t.Fatalf("decode stored learned: %v", err)
232+ }
233+ stored = append(stored, registration)
181234 }
182- if !slices.Equal(decoded.Pinned, wantPinned) {
235+ if !slices.Equal(registrationEndpoints(stored[:len(decoded.Pinned)]), wantPinned) {
183236 t.Fatalf("stored pinned = %v, want %v", decoded.Pinned, wantPinned)
184237 }
185- if !slices.Equal(decoded.Learned, wantLearned) {
238+ if !slices.Equal(registrationEndpoints(stored[len(decoded.Pinned):]), wantLearned) {
186239 t.Fatalf("stored learned = %v, want %v", decoded.Learned, wantLearned)
187240 }
241+ requireValidRegistrations(t, stored)
188242 })
189243
190244 t.Run("overlap allowed across pinned and learned", func(t *testing.T) {
@@ -202,12 +256,17 @@ func TestHostStore(t *testing.T) {
202256 if err != nil {
203257 t.Fatalf("Hosts() error = %v", err)
204258 }
205- if !slices.Equal(pinned, []string{"arch"}) {
259+ if !slices.Equal(registrationEndpoints(pinned), []string{"arch"}) {
206260 t.Fatalf("pinned = %v, want [arch]", pinned)
207261 }
208- if !slices.Equal(learned, []string{"arch"}) {
262+ if !slices.Equal(registrationEndpoints(learned), []string{"arch"}) {
209263 t.Fatalf("learned = %v, want [arch]", learned)
210264 }
265+ // Overlap shares one registration: pinning a learned host and
266+ // remembering a pinned host retain the incarnation.
267+ if !pinned[0].Equal(learned[0]) {
268+ t.Fatalf("overlapping registrations differ: pinned %+v learned %+v", pinned[0], learned[0])
269+ }
211270 })
212271
213272 t.Run("idempotent mutations and remove from both sources", func(t *testing.T) {
@@ -255,7 +314,7 @@ func TestHostStore(t *testing.T) {
255314 if len(pinned) != 0 {
256315 t.Fatalf("pinned after Remove(arch) = %v, want empty", pinned)
257316 }
258- if !slices.Equal(learned, []string{"mule"}) {
317+ if !slices.Equal(registrationEndpoints(learned), []string{"mule"}) {
259318 t.Fatalf("learned after Remove(arch) = %v, want [mule]", learned)
260319 }
261320
@@ -303,7 +362,7 @@ func TestHostStore(t *testing.T) {
303362 }
304363 })
305364
306- t.Run("legacy version 1 migrates on mutation", func(t *testing.T) {
365+ t.Run("legacy version 1 migrates on read with backup", func(t *testing.T) {
307366 t.Parallel()
308367 cases := []struct {
309368 name string
@@ -324,21 +383,13 @@ func TestHostStore(t *testing.T) {
324383 if err != nil {
325384 t.Fatalf("Hosts() error = %v", err)
326385 }
327- if len(pinned) != 0 || !slices.Equal(learned, tc.wantLearned) {
386+ if len(pinned) != 0 || !slices.Equal(registrationEndpoints(learned), tc.wantLearned) {
328387 t.Fatalf("Hosts() = pinned %v learned %v, want pinned empty learned %v", pinned, learned, tc.wantLearned)
329388 }
389+ requireValidRegistrations(t, learned)
390+ // The read migrated: the file is now version 3 and the
391+ // pre-migration bytes are preserved privately.
330392 raw, err := os.ReadFile(path)
331- if err != nil {
332- t.Fatalf("read legacy store: %v", err)
333- }
334- if string(raw) != tc.raw {
335- t.Fatalf("read-only Hosts rewrote file: got %q, want %q", raw, tc.raw)
336- }
337-
338- if err := store.AddPinned("zebra"); err != nil {
339- t.Fatalf("AddPinned() error = %v", err)
340- }
341- raw, err = os.ReadFile(path)
342393 if err != nil {
343394 t.Fatalf("read migrated store: %v", err)
344395 }
@@ -349,13 +400,163 @@ func TestHostStore(t *testing.T) {
349400 if decoded.Version != hostsFileVersion {
350401 t.Fatalf("migrated version = %d, want %d", decoded.Version, hostsFileVersion)
351402 }
352- if !slices.Equal(decoded.Pinned, []string{"zebra"}) || !slices.Equal(decoded.Learned, tc.wantLearned) {
353- t.Fatalf("migrated store = pinned %v learned %v", decoded.Pinned, decoded.Learned)
403+ backup, err := os.ReadFile(path + hostsBackupSuffix)
404+ if err != nil {
405+ t.Fatalf("read migration backup: %v", err)
406+ }
407+ if string(backup) != tc.raw {
408+ t.Fatalf("backup = %q, want pre-migration %q", backup, tc.raw)
409+ }
410+ info, err := os.Stat(path + hostsBackupSuffix)
411+ if err != nil {
412+ t.Fatalf("stat backup: %v", err)
413+ }
414+ if perm := info.Mode().Perm(); perm != 0o600 {
415+ t.Fatalf("backup permissions = %04o, want 0600", perm)
416+ }
417+ // A repeated load retains the migrated identity.
418+ againPinned, againLearned, err := store.Hosts()
419+ if err != nil {
420+ t.Fatalf("Hosts() again error = %v", err)
421+ }
422+ if len(againPinned) != 0 || len(againLearned) != len(learned) {
423+ t.Fatalf("Hosts() again = pinned %v learned %v", againPinned, againLearned)
424+ }
425+ for i := range learned {
426+ if !learned[i].Equal(againLearned[i]) {
427+ t.Fatalf("identity not retained: %+v vs %+v", learned[i], againLearned[i])
428+ }
354429 }
355430 })
356431 }
357432 })
358433
434+ t.Run("legacy version 2 migrates on read preserving order", func(t *testing.T) {
435+ t.Parallel()
436+ path := storePath(t)
437+ legacy := `{"version":2,"pinned":["zebra","arch"],"learned":["mule","beta"]}`
438+ writeHosts(t, path, []byte(legacy))
439+ store := NewFileHostStore(path)
440+
441+ pinned, learned, err := store.Hosts()
442+ if err != nil {
443+ t.Fatalf("Hosts() error = %v", err)
444+ }
445+ if !slices.Equal(registrationEndpoints(pinned), []string{"zebra", "arch"}) {
446+ t.Fatalf("pinned = %v, want [zebra arch]", pinned)
447+ }
448+ if !slices.Equal(registrationEndpoints(learned), []string{"beta", "mule"}) {
449+ t.Fatalf("learned = %v, want [beta mule]", learned)
450+ }
451+ requireValidRegistrations(t, pinned)
452+ requireValidRegistrations(t, learned)
453+
454+ raw, err := os.ReadFile(path)
455+ if err != nil {
456+ t.Fatalf("read migrated store: %v", err)
457+ }
458+ var decoded hostsFile
459+ if err := json.Unmarshal(raw, &decoded); err != nil {
460+ t.Fatalf("decode migrated store: %v", err)
461+ }
462+ if decoded.Version != hostsFileVersion {
463+ t.Fatalf("migrated version = %d, want %d", decoded.Version, hostsFileVersion)
464+ }
465+ backup, err := os.ReadFile(path + hostsBackupSuffix)
466+ if err != nil {
467+ t.Fatalf("read migration backup: %v", err)
468+ }
469+ if string(backup) != legacy {
470+ t.Fatalf("backup = %q, want pre-migration %q", backup, legacy)
471+ }
472+ })
473+
474+ t.Run("legacy version 2 overlapping membership shares one registration", func(t *testing.T) {
475+ t.Parallel()
476+ path := storePath(t)
477+ legacy := `{"version":2,"pinned":["arch"],"learned":["arch","mule"]}`
478+ writeHosts(t, path, []byte(legacy))
479+ store := NewFileHostStore(path)
480+
481+ pinned, learned, err := store.Hosts()
482+ if err != nil {
483+ t.Fatalf("Hosts() error = %v", err)
484+ }
485+ requireValidRegistrations(t, pinned)
486+ requireValidRegistrations(t, learned)
487+ if len(pinned) != 1 || len(learned) != 2 {
488+ t.Fatalf("pinned = %v, learned = %v, want 1 pinned and 2 learned", pinned, learned)
489+ }
490+ if pinned[0] != learned[0] {
491+ t.Fatalf("overlapping endpoint has two incarnations: pinned %+v vs learned %+v", pinned[0], learned[0])
492+ }
493+ // Removing only the pin must not surface a second identity
494+ // for an endpoint that was never fully removed.
495+ if err := store.RemovePinned("arch"); err != nil {
496+ t.Fatalf("RemovePinned(arch) error = %v", err)
497+ }
498+ _, againLearned, err := store.Hosts()
499+ if err != nil {
500+ t.Fatalf("Hosts() error = %v", err)
501+ }
502+ if len(againLearned) != 2 || againLearned[0] != pinned[0] {
503+ t.Fatalf("identity not retained after unpin: %+v vs %+v", againLearned, pinned)
504+ }
505+ })
506+
507+ t.Run("conflicting version 3 overlap fails closed", func(t *testing.T) {
508+ t.Parallel()
509+ path := storePath(t)
510+ conflict := `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"01000000000000000000000000000000","generation":1}],"learned":[{"endpoint":"arch","incarnation":"02000000000000000000000000000000","generation":1}]}`
511+ writeHosts(t, path, []byte(conflict))
512+ store := NewFileHostStore(path)
513+
514+ if _, _, err := store.Hosts(); err == nil {
515+ t.Fatal("Hosts() error = nil, want conflicting identity rejection")
516+ }
517+ raw, err := os.ReadFile(path)
518+ if err != nil {
519+ t.Fatalf("read store: %v", err)
520+ }
521+ if string(raw) != conflict {
522+ t.Fatalf("rejected store changed: got %q, want %q", raw, conflict)
523+ }
524+ })
525+
526+ t.Run("remove and re-add creates a new incarnation", func(t *testing.T) {
527+ t.Parallel()
528+ path := storePath(t)
529+ store := NewFileHostStore(path)
530+ if err := store.Remember("arch"); err != nil {
531+ t.Fatalf("Remember(arch) error = %v", err)
532+ }
533+ _, learned, err := store.Hosts()
534+ if err != nil {
535+ t.Fatalf("Hosts() error = %v", err)
536+ }
537+ first := learned[0]
538+
539+ if deleted, err := store.Remove("arch"); err != nil || !deleted {
540+ t.Fatalf("Remove(arch) = %t, %v", deleted, err)
541+ }
542+ if err := store.Remember("arch"); err != nil {
543+ t.Fatalf("Remember(arch) again error = %v", err)
544+ }
545+ _, relearned, err := store.Hosts()
546+ if err != nil {
547+ t.Fatalf("Hosts() again error = %v", err)
548+ }
549+ if len(relearned) != 1 {
550+ t.Fatalf("Hosts() again = %v, want one registration", relearned)
551+ }
552+ if relearned[0].Equal(first) {
553+ t.Fatalf("re-added registration %+v must not equal removed %+v", relearned[0], first)
554+ }
555+ if relearned[0].Incarnation == first.Incarnation {
556+ t.Fatalf("re-added incarnation reused after full removal")
557+ }
558+ })
559+
359560 t.Run("schema failures fail closed and preserve file", func(t *testing.T) {
360561 t.Parallel()
361562 cases := []struct {
@@ -363,7 +564,7 @@ func TestHostStore(t *testing.T) {
363564 raw string
364565 wantError string
365566 }{
366- {name: "unsupported version", raw: `{"version":3,"pinned":["arch"],"learned":[]}`, wantError: "unsupported hosts file version 3"},
567+ {name: "unsupported version", raw: `{"version":99,"pinned":[],"learned":[]}`, wantError: "unsupported hosts file version 99"},
367568 {name: "null pinned", raw: `{"version":2,"pinned":null,"learned":[]}`, wantError: "malformed hosts file"},
368569 {name: "null learned", raw: `{"version":2,"pinned":[],"learned":null}`, wantError: "malformed hosts file"},
369570 {name: "duplicate pinned", raw: `{"version":2,"pinned":["arch","arch"],"learned":[]}`, wantError: "malformed hosts file"},
@@ -375,6 +576,13 @@ func TestHostStore(t *testing.T) {
375576 {name: "legacy duplicate hosts", raw: `{"version":1,"hosts":["arch","arch"]}`, wantError: "malformed hosts file: duplicate learned host"},
376577 {name: "legacy invalid target", raw: `{"version":1,"hosts":[" arch"]}`, wantError: "malformed hosts file"},
377578 {name: "legacy invalid utf8", raw: "{\"version\":1,\"hosts\":[\"\xff\"]}", wantError: "malformed hosts file"},
579+ {name: "v3 string pinned is malformed", raw: `{"version":3,"pinned":["arch"],"learned":[]}`, wantError: "malformed hosts file"},
580+ {name: "v3 missing incarnation", raw: `{"version":3,"pinned":[{"endpoint":"arch","generation":1}],"learned":[]}`, wantError: "malformed hosts file"},
581+ {name: "v3 malformed incarnation", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"zzzz","generation":1}],"learned":[]}`, wantError: "malformed hosts file"},
582+ {name: "v3 short incarnation", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"0123","generation":1}],"learned":[]}`, wantError: "malformed hosts file"},
583+ {name: "v3 zero incarnation", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"00000000000000000000000000000000","generation":1}],"learned":[]}`, wantError: "malformed hosts file"},
584+ {name: "v3 zero generation", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"01000000000000000000000000000000","generation":0}],"learned":[]}`, wantError: "malformed hosts file"},
585+ {name: "v3 duplicate pinned", raw: `{"version":3,"pinned":[{"endpoint":"arch","incarnation":"01000000000000000000000000000000","generation":1},{"endpoint":"arch","incarnation":"02000000000000000000000000000000","generation":1}],"learned":[]}`, wantError: "duplicate pinned host"},
378586 }
379587 for _, tc := range cases {
380588 t.Run(tc.name, func(t *testing.T) {
@@ -402,28 +610,30 @@ func TestHostStore(t *testing.T) {
402610 }
403611 })
404612
405- t.Run("sorts unsorted learned on read without rewriting until mutation", func(t *testing.T) {
613+ t.Run("reads v3 without rewriting until mutation", func(t *testing.T) {
406614 t.Parallel()
407615 path := storePath(t)
408- unsorted := []byte(`{"version":2,"pinned":["zebra","arch"],"learned":["mule","beta"]}` + "\n")
409- writeHosts(t, path, unsorted)
616+ seeded := `{"version":3,"pinned":[{"endpoint":"zebra","incarnation":"01000000000000000000000000000000","generation":1},{"endpoint":"arch","incarnation":"02000000000000000000000000000000","generation":1}],"learned":[{"endpoint":"mule","incarnation":"03000000000000000000000000000000","generation":1},{"endpoint":"beta","incarnation":"04000000000000000000000000000000","generation":1}]}` + "\n"
617+ writeHosts(t, path, []byte(seeded))
410618
411619 store := NewFileHostStore(path)
412620 pinned, learned, err := store.Hosts()
413621 if err != nil {
414622 t.Fatalf("Hosts() error = %v", err)
415623 }
416- if !slices.Equal(pinned, []string{"zebra", "arch"}) {
624+ if !slices.Equal(registrationEndpoints(pinned), []string{"zebra", "arch"}) {
417625 t.Fatalf("pinned = %v, want [zebra arch]", pinned)
418626 }
419- if !slices.Equal(learned, []string{"beta", "mule"}) {
627+ if !slices.Equal(registrationEndpoints(learned), []string{"beta", "mule"}) {
420628 t.Fatalf("learned = %v, want [beta mule]", learned)
421629 }
630+ // Identity survives the read; incarnations are stable.
631+ beforePinned, beforeLearned := pinned, learned
422632 raw, err := os.ReadFile(path)
423633 if err != nil {
424634 t.Fatalf("read store: %v", err)
425635 }
426- if string(raw) != string(unsorted) {
636+ if string(raw) != seeded {
427637 t.Fatalf("read-only Hosts rewrote file: got %q", raw)
428638 }
429639
@@ -438,12 +648,34 @@ func TestHostStore(t *testing.T) {
438648 if err := json.Unmarshal(raw, &decoded); err != nil {
439649 t.Fatalf("decode: %v", err)
440650 }
441- if !slices.Equal(decoded.Pinned, []string{"zebra", "arch"}) {
651+ afterPinned, err := validatePinnedRecords(decoded.Pinned)
652+ if err != nil {
653+ t.Fatalf("decode pinned: %v", err)
654+ }
655+ afterLearned, err := validateLearnedRecords(decoded.Learned)
656+ if err != nil {
657+ t.Fatalf("decode learned: %v", err)
658+ }
659+ if !slices.Equal(registrationEndpoints(afterPinned), []string{"zebra", "arch"}) {
442660 t.Fatalf("pinned after mutation = %v", decoded.Pinned)
443661 }
444- if !slices.Equal(decoded.Learned, []string{"beta", "gamma", "mule"}) {
662+ if !slices.Equal(registrationEndpoints(sortedRegistrations(afterLearned)), []string{"beta", "gamma", "mule"}) {
445663 t.Fatalf("learned after mutation = %v", decoded.Learned)
446664 }
665+ // Unrelated registrations keep their incarnations across mutation.
666+ for i := range beforePinned {
667+ if !beforePinned[i].Equal(afterPinned[i]) {
668+ t.Fatalf("pinned identity changed: %+v vs %+v", beforePinned[i], afterPinned[i])
669+ }
670+ }
671+ for endpoint, before := range map[string]domain.RemoteRegistration{
672+ "mule": beforeLearned[1], "beta": beforeLearned[0],
673+ } {
674+ after, ok := afterLearned[endpoint]
675+ if !ok || !before.Equal(after) {
676+ t.Fatalf("learned identity changed for %q: %+v vs %+v", endpoint, before, after)
677+ }
678+ }
447679 })
448680
449681 t.Run("rejects invalid targets", func(t *testing.T) {
@@ -650,15 +882,15 @@ func TestHostStore(t *testing.T) {
650882 }
651883 wantLearned := append([]string{}, left...)
652884 sort.Strings(wantLearned)
653- if !slices.Equal(learned, wantLearned) {
885+ if !slices.Equal(registrationEndpoints(learned), wantLearned) {
654886 t.Fatalf("learned len=%d want %d; missing merges under dual writers", len(learned), len(wantLearned))
655887 }
656888 if len(pinned) != len(right) {
657889 t.Fatalf("pinned len=%d want %d", len(pinned), len(right))
658890 }
659891 pinnedSet := make(map[string]struct{}, len(pinned))
660- for _, host := range pinned {
661- pinnedSet[host] = struct{}{}
892+ for _, record := range pinned {
893+ pinnedSet[record.Endpoint] = struct{}{}
662894 }
663895 for _, host := range right {
664896 if _, ok := pinnedSet[host]; !ok {
A internal/adapters/remote/runtime.go +215 -0
@@ -0,0 +1,215 @@
1+package remote
2+
3+import (
4+ "context"
5+ "errors"
6+ "log/slog"
7+ "sync"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ appports "github.com/bnema/vev/internal/ports"
12+)
13+
14+const (
15+ // observeWorkerCount bounds concurrent catalogue observations so four
16+ // hung checks can never starve the registry, cache, or store lanes.
17+ observeWorkerCount = 4
18+ // defaultObserveTimeout bounds one catalogue observation when the
19+ // admitted job carries no deadline.
20+ defaultObserveTimeout = 10 * time.Second
21+)
22+
23+// errRemoteLaneBusy retires a job the service admitted without credit. The
24+// service never issues more jobs of a kind than its credits; this is
25+// defense-in-depth so a wedged admission cannot wedge the runtime.
26+var errRemoteLaneBusy = errors.New("remote runtime: bounded lane occupied")
27+
28+// Runtime is the bounded infrastructure seam executing remote jobs. It owns
29+// subprocesses, storage calls and per-lane dispatch; its workers never call
30+// into the use case. Lanes are independent: one registry reader, one startup
31+// cache reader, one cache writer, four catalogue observers.
32+type Runtime struct {
33+ store appports.RemoteHostStore
34+ catalog appports.RemoteCatalogClient
35+ cache appports.RemoteCatalogCache
36+ log *slog.Logger
37+}
38+
39+// NewRuntime composes a Runtime from the existing registry, catalogue and
40+// cache adapters. All arguments must be non-nil.
41+func NewRuntime(store appports.RemoteHostStore, catalog appports.RemoteCatalogClient, cache appports.RemoteCatalogCache, log *slog.Logger) *Runtime {
42+ if log == nil {
43+ log = slog.Default()
44+ }
45+ return &Runtime{store: store, catalog: catalog, cache: cache, log: log}
46+}
47+
48+var _ appports.RemoteRuntime = (*Runtime)(nil)
49+
50+// Run executes admitted jobs until ctx is cancelled or the job channel
51+// closes. Dispatch never blocks forwarding into an occupied lane; cancelled
52+// completions select on context cancellation. It returns nil on cooperative
53+// shutdown; lane failures travel through results, never through Run.
54+// Cancellation is owned explicitly: workers run under a derived context so
55+// closing the job channel retires in-flight delivery and reaps every worker
56+// instead of hanging behind idle lanes with a live parent context.
57+func (r *Runtime) Run(ctx context.Context, jobs <-chan appports.RemoteJob, results chan<- appports.RemoteJobResult) error {
58+ runCtx, stop := context.WithCancel(ctx)
59+ defer stop()
60+ registryLane := make(chan appports.RemoteJob, 1)
61+ cacheLoadLane := make(chan appports.RemoteJob, 1)
62+ storeLane := make(chan appports.RemoteJob, 1)
63+ // The observe buffer matches the worker count: under correct service
64+ // credits forwarding never blocks, and scheduling jitter between rapid
65+ // admissions cannot retire a properly admitted job as lane-busy.
66+ observeLane := make(chan appports.RemoteJob, observeWorkerCount)
67+
68+ var workers sync.WaitGroup
69+ ready := make(chan struct{}, observeWorkerCount+3)
70+ park := func(lane <-chan appports.RemoteJob, work func(appports.RemoteJob)) {
71+ defer workers.Done()
72+ ready <- struct{}{}
73+ for {
74+ select {
75+ case <-runCtx.Done():
76+ return
77+ case job, ok := <-lane:
78+ if !ok {
79+ return
80+ }
81+ work(job)
82+ }
83+ }
84+ }
85+ workers.Add(1)
86+ go park(registryLane, func(job appports.RemoteJob) {
87+ pinned, learned, err := r.store.Hosts()
88+ r.complete(runCtx, results, appports.RemoteJobResult{
89+ Kind: job.Kind, Endpoint: job.Endpoint,
90+ Registration: job.Registration, Generation: job.Generation,
91+ Registrations: combineRegistrations(pinned, learned), Err: err,
92+ })
93+ })
94+ workers.Add(1)
95+ go park(cacheLoadLane, func(job appports.RemoteJob) {
96+ entries, err := r.cache.Load()
97+ r.complete(runCtx, results, appports.RemoteJobResult{
98+ Kind: job.Kind, Endpoint: job.Endpoint,
99+ Registration: job.Registration, Generation: job.Generation,
100+ Entries: entries, Err: err,
101+ })
102+ })
103+ workers.Add(1)
104+ go park(storeLane, func(job appports.RemoteJob) {
105+ err := r.cache.Store(job.Entries)
106+ r.complete(runCtx, results, appports.RemoteJobResult{
107+ Kind: job.Kind, Endpoint: job.Endpoint,
108+ Registration: job.Registration, Generation: job.Generation,
109+ Err: err,
110+ })
111+ })
112+ for i := 0; i < observeWorkerCount; i++ {
113+ workers.Add(1)
114+ go park(observeLane, func(job appports.RemoteJob) {
115+ timeout := job.Deadline
116+ if timeout <= 0 {
117+ timeout = defaultObserveTimeout
118+ }
119+ observeCtx, cancel := context.WithTimeout(runCtx, timeout)
120+ catalog, err := r.catalog.List(observeCtx, job.Endpoint)
121+ cancel()
122+ r.complete(runCtx, results, appports.RemoteJobResult{
123+ Kind: job.Kind, Endpoint: job.Endpoint,
124+ Registration: job.Registration, Generation: job.Generation,
125+ FailureKind: ClassifyCatalogError(err), Catalog: catalog, Err: err,
126+ })
127+ })
128+ }
129+ // All lanes parked before the first admission: an immediate send can
130+ // never retire as lane-busy for lack of a ready receiver.
131+ for i := 0; i < observeWorkerCount+3; i++ {
132+ select {
133+ case <-ready:
134+ case <-ctx.Done():
135+ workers.Wait()
136+ return nil
137+ }
138+ }
139+
140+ for {
141+ select {
142+ case <-ctx.Done():
143+ workers.Wait()
144+ return nil
145+ case job, ok := <-jobs:
146+ if !ok {
147+ stop()
148+ workers.Wait()
149+ return nil
150+ }
151+ var lane chan appports.RemoteJob
152+ switch job.Kind {
153+ case appports.RemoteJobRegistryRead:
154+ lane = registryLane
155+ case appports.RemoteJobCacheLoad:
156+ lane = cacheLoadLane
157+ case appports.RemoteJobCacheStore:
158+ lane = storeLane
159+ case appports.RemoteJobCatalogObserve:
160+ lane = observeLane
161+ default:
162+ r.complete(runCtx, results, appports.RemoteJobResult{
163+ Kind: job.Kind, Endpoint: job.Endpoint,
164+ Registration: job.Registration, Generation: job.Generation,
165+ Err: errors.New("remote runtime: unknown job kind"),
166+ })
167+ continue
168+ }
169+ select {
170+ case lane <- job:
171+ case <-ctx.Done():
172+ workers.Wait()
173+ return nil
174+ default:
175+ r.log.Debug("remote runtime lane occupied, retiring job", "kind", job.Kind.String(), "endpoint", job.Endpoint)
176+ r.complete(runCtx, results, appports.RemoteJobResult{
177+ Kind: job.Kind, Endpoint: job.Endpoint,
178+ Registration: job.Registration, Generation: job.Generation,
179+ Err: errRemoteLaneBusy,
180+ })
181+ }
182+ }
183+ }
184+}
185+
186+// complete delivers exactly one completion, abandoning delivery when the
187+// service is already gone so shutdown never wedges on an unread result.
188+func (r *Runtime) complete(ctx context.Context, results chan<- appports.RemoteJobResult, result appports.RemoteJobResult) {
189+ select {
190+ case results <- result:
191+ case <-ctx.Done():
192+ }
193+}
194+
195+// combineRegistrations orders pinned records first in stored order, then
196+// learned-only records, so the monitor can assign stable presentation ranks.
197+func combineRegistrations(pinned, learned []domain.RemoteRegistration) []domain.RemoteRegistration {
198+ out := make([]domain.RemoteRegistration, 0, len(pinned)+len(learned))
199+ seen := make(map[string]struct{}, len(pinned)+len(learned))
200+ for _, record := range pinned {
201+ if _, dup := seen[record.Endpoint]; dup {
202+ continue
203+ }
204+ seen[record.Endpoint] = struct{}{}
205+ out = append(out, record)
206+ }
207+ for _, record := range learned {
208+ if _, dup := seen[record.Endpoint]; dup {
209+ continue
210+ }
211+ seen[record.Endpoint] = struct{}{}
212+ out = append(out, record)
213+ }
214+ return out
215+}
A internal/adapters/remote/runtime_test.go +343 -0
@@ -0,0 +1,343 @@
1+package remote
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ appports "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/protocol/catalogue"
13+)
14+
15+type blockingCache struct {
16+ release chan struct{}
17+ done chan struct{}
18+ mu sync.Mutex
19+ stores [][]catalogue.RemoteCatalogCacheEntry
20+}
21+
22+func newBlockingCache(t *testing.T) *blockingCache {
23+ t.Helper()
24+ cache := &blockingCache{release: make(chan struct{}), done: make(chan struct{})}
25+ t.Cleanup(func() {
26+ select {
27+ case <-cache.done:
28+ default:
29+ close(cache.done)
30+ }
31+ })
32+ return cache
33+}
34+
35+func (c *blockingCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { return nil, nil }
36+
37+func (c *blockingCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
38+ c.mu.Lock()
39+ c.stores = append(c.stores, entries)
40+ c.mu.Unlock()
41+ select {
42+ case <-c.release:
43+ return nil
44+ case <-c.done:
45+ return errors.New("test teardown releases blocked store")
46+ }
47+}
48+
49+type instantCatalog struct {
50+ mu sync.Mutex
51+ calls []string
52+ deadline []time.Duration
53+}
54+
55+func (c *instantCatalog) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) {
56+ deadline, _ := ctx.Deadline()
57+ c.mu.Lock()
58+ c.calls = append(c.calls, target)
59+ c.deadline = append(c.deadline, time.Until(deadline))
60+ c.mu.Unlock()
61+ return catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "work"}}}, nil
62+}
63+
64+type hangingCatalog struct{ started chan struct{} }
65+
66+func (c *hangingCatalog) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) {
67+ close(c.started)
68+ <-ctx.Done()
69+ return catalogue.RemoteCatalog{}, ctx.Err()
70+}
71+
72+type recordStore struct {
73+ pinned []domain.RemoteRegistration
74+ learned []domain.RemoteRegistration
75+ err error
76+}
77+
78+func testRecord(endpoint string, incarnation byte) domain.RemoteRegistration {
79+ return domain.RemoteRegistration{Endpoint: endpoint, Incarnation: [16]byte{incarnation}, Generation: 1}
80+}
81+
82+func (s *recordStore) Hosts() ([]domain.RemoteRegistration, []domain.RemoteRegistration, error) {
83+ return s.pinned, s.learned, s.err
84+}
85+func (*recordStore) AddPinned(string) error { return nil }
86+func (*recordStore) RemovePinned(string) error { return nil }
87+func (*recordStore) Remember(string) error { return nil }
88+func (*recordStore) Forget(string) error { return nil }
89+func (*recordStore) Remove(string) (bool, error) {
90+ return false, nil
91+}
92+
93+func startRuntime(t *testing.T, runtime *Runtime) (chan<- appports.RemoteJob, <-chan appports.RemoteJobResult, context.CancelFunc) {
94+ t.Helper()
95+ ctx, cancel := context.WithCancel(context.Background())
96+ jobs := make(chan appports.RemoteJob, 16)
97+ results := make(chan appports.RemoteJobResult, 16)
98+ done := make(chan error, 1)
99+ go func() { done <- runtime.Run(ctx, jobs, results) }()
100+ t.Cleanup(func() {
101+ cancel()
102+ select {
103+ case <-done:
104+ case <-time.After(5 * time.Second):
105+ t.Fatalf("runtime did not stop")
106+ }
107+ })
108+ return jobs, results, cancel
109+}
110+
111+func receiveResult(t *testing.T, ch <-chan appports.RemoteJobResult, what string) appports.RemoteJobResult {
112+ t.Helper()
113+ select {
114+ case result := <-ch:
115+ return result
116+ case <-time.After(5 * time.Second):
117+ t.Fatalf("timed out waiting for %s", what)
118+ return appports.RemoteJobResult{}
119+ }
120+}
121+
122+func TestRuntimeBlockedWriterDoesNotStallOtherLanes(t *testing.T) {
123+ t.Parallel()
124+
125+ cache := &blockingCache{release: make(chan struct{})}
126+ catalog := &instantCatalog{}
127+ store := &recordStore{
128+ pinned: []domain.RemoteRegistration{testRecord("user@arch", 1)},
129+ learned: []domain.RemoteRegistration{testRecord("user@mule", 2)},
130+ }
131+ runtime := NewRuntime(store, catalog, cache, nil)
132+ jobs, results, _ := startRuntime(t, runtime)
133+ // Release the blocked writer on any early failure so the runtime
134+ // cleanup's bounded join never wedges behind this test's Fatalf.
135+ t.Cleanup(func() {
136+ select {
137+ case <-cache.release:
138+ default:
139+ close(cache.release)
140+ }
141+ })
142+
143+ // Occupy the single cache writer, then prove registry reads and all
144+ // four observation lanes make progress around the blocked write.
145+ jobs <- appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "user@arch"}}}
146+ jobs <- appports.RemoteJob{Kind: appports.RemoteJobRegistryRead}
147+ for _, endpoint := range []string{"a", "b", "c", "d"} {
148+ jobs <- appports.RemoteJob{Kind: appports.RemoteJobCatalogObserve, Endpoint: endpoint}
149+ }
150+
151+ registry := appports.RemoteJobResult{}
152+ observes := 0
153+ // Lanes run independently: the registry read and the four
154+ // observations complete in arrival order, not admission order.
155+ for i := 0; i < 5; i++ {
156+ result := receiveResult(t, results, "registry or observe")
157+ switch result.Kind {
158+ case appports.RemoteJobRegistryRead:
159+ if registry.Kind != 0 {
160+ t.Fatalf("duplicate registry result = %+v", result)
161+ }
162+ registry = result
163+ case appports.RemoteJobCatalogObserve:
164+ if result.Err != nil || result.FailureKind != domain.RemoteFailureNone {
165+ t.Fatalf("observe result = %+v", result)
166+ }
167+ if len(result.Catalog.Sessions) != 1 {
168+ t.Fatalf("observe catalog = %+v", result.Catalog)
169+ }
170+ observes++
171+ default:
172+ t.Fatalf("unexpected result kind = %+v", result)
173+ }
174+ }
175+ if registry.Err != nil {
176+ t.Fatalf("registry result = %+v", registry)
177+ }
178+ if len(registry.Registrations) != 2 || registry.Registrations[0].Endpoint != "user@arch" || registry.Registrations[1].Endpoint != "user@mule" {
179+ t.Fatalf("registrations = %+v, want pinned-then-learned order", registry.Registrations)
180+ }
181+ if observes != 4 {
182+ t.Fatalf("observes = %d, want 4", observes)
183+ }
184+
185+ // A second store while the writer is occupied queues behind it; a third
186+ // concurrent store cannot enter the lane and retires with a lane-busy
187+ // failure instead of wedging dispatch. The service never issues either:
188+ // it keeps one active write plus one replaceable pending snapshot.
189+ second := appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "second"}}}
190+ third := appports.RemoteJob{Kind: appports.RemoteJobCacheStore, Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "third"}}}
191+ jobs <- second
192+ jobs <- third
193+ duplicate := receiveResult(t, results, "duplicate store")
194+ if duplicate.Kind != appports.RemoteJobCacheStore || !errors.Is(duplicate.Err, errRemoteLaneBusy) {
195+ t.Fatalf("duplicate store result = %+v, want lane-busy failure", duplicate)
196+ }
197+
198+ close(cache.release)
199+ firstStored := receiveResult(t, results, "cache store")
200+ if firstStored.Kind != appports.RemoteJobCacheStore || firstStored.Err != nil {
201+ t.Fatalf("store result = %+v", firstStored)
202+ }
203+ secondStored := receiveResult(t, results, "queued cache store")
204+ if secondStored.Kind != appports.RemoteJobCacheStore || secondStored.Err != nil {
205+ t.Fatalf("queued store result = %+v", secondStored)
206+ }
207+ cache.mu.Lock()
208+ defer cache.mu.Unlock()
209+ if len(cache.stores) != 2 || len(cache.stores[0]) != 1 || cache.stores[0][0].Host != "user@arch" ||
210+ len(cache.stores[1]) != 1 || cache.stores[1][0].Host != "second" {
211+ t.Fatalf("stored payloads = %+v, want serialized first then second", cache.stores)
212+ }
213+}
214+
215+func TestRuntimeObserveHonorsDeadlineAndCancel(t *testing.T) {
216+ t.Parallel()
217+
218+ catalog := &instantCatalog{}
219+ runtime := NewRuntime(&recordStore{}, catalog, &blockingCache{release: make(chan struct{})}, nil)
220+ jobs, results, _ := startRuntime(t, runtime)
221+
222+ jobs <- appports.RemoteJob{Kind: appports.RemoteJobCatalogObserve, Endpoint: "user@arch", Deadline: 5 * time.Second}
223+ result := receiveResult(t, results, "bounded observe")
224+ if result.Err != nil {
225+ t.Fatalf("observe result = %+v", result)
226+ }
227+ catalog.mu.Lock()
228+ defer catalog.mu.Unlock()
229+ if len(catalog.calls) != 1 || catalog.calls[0] != "user@arch" {
230+ t.Fatalf("catalog calls = %v", catalog.calls)
231+ }
232+ if catalog.deadline[0] <= 0 || catalog.deadline[0] > 5*time.Second {
233+ t.Fatalf("observe ctx deadline in %v, want within (0, 5s]", catalog.deadline[0])
234+ }
235+}
236+
237+func TestRuntimeObserveCancelReapsWorker(t *testing.T) {
238+ t.Parallel()
239+
240+ catalog := &hangingCatalog{started: make(chan struct{})}
241+ runtime := NewRuntime(&recordStore{}, catalog, &blockingCache{release: make(chan struct{})}, nil)
242+ ctx, cancel := context.WithCancel(context.Background())
243+ jobs := make(chan appports.RemoteJob, 4)
244+ results := make(chan appports.RemoteJobResult, 4)
245+ done := make(chan error, 1)
246+ go func() { done <- runtime.Run(ctx, jobs, results) }()
247+
248+ jobs <- appports.RemoteJob{Kind: appports.RemoteJobCatalogObserve, Endpoint: "user@arch", Deadline: time.Minute}
249+ select {
250+ case <-catalog.started:
251+ case <-time.After(5 * time.Second):
252+ t.Fatal("observation did not start")
253+ }
254+ cancel()
255+ // Delivery is abandoned once the runtime context is cancelled, so a
256+ // cancelled observation either reports context.Canceled or never
257+ // reports at all; either way the runtime itself must stop. Note done
258+ // is buffered depth one: once observed stopped, do not wait on it
259+ // again.
260+ stopped := false
261+ select {
262+ case result := <-results:
263+ if !errors.Is(result.Err, context.Canceled) {
264+ t.Fatalf("cancelled observe err = %v, want context.Canceled", result.Err)
265+ }
266+ case <-done:
267+ stopped = true
268+ case <-time.After(5 * time.Second):
269+ t.Fatal("cancelled observation neither completed nor stopped the runtime")
270+ }
271+ if !stopped {
272+ select {
273+ case <-done:
274+ case <-time.After(5 * time.Second):
275+ t.Fatal("runtime did not stop after cancel")
276+ }
277+ }
278+}
279+
280+func TestRuntimeJobsCloseReapsWorkers(t *testing.T) {
281+ t.Parallel()
282+
283+ // Closing the job channel with a live parent context must still
284+ // reap every worker: the runtime owns cancellation explicitly
285+ // instead of hanging behind idle lanes.
286+ runtime := NewRuntime(&recordStore{}, &instantCatalog{}, &blockingCache{release: make(chan struct{})}, nil)
287+ jobs := make(chan appports.RemoteJob, 4)
288+ results := make(chan appports.RemoteJobResult, 4)
289+ done := make(chan error, 1)
290+ go func() { done <- runtime.Run(context.Background(), jobs, results) }()
291+
292+ close(jobs)
293+ select {
294+ case err := <-done:
295+ if err != nil {
296+ t.Fatalf("Run() error = %v, want nil", err)
297+ }
298+ case <-time.After(5 * time.Second):
299+ t.Fatal("runtime did not stop after jobs closed with live context")
300+ }
301+}
302+
303+func TestRuntimeCancelledCompletionDoesNotWedge(t *testing.T) {
304+ t.Parallel()
305+
306+ runtime := NewRuntime(&recordStore{}, &instantCatalog{}, &blockingCache{release: make(chan struct{})}, nil)
307+ ctx, cancel := context.WithCancel(context.Background())
308+ jobs := make(chan appports.RemoteJob, 4)
309+ // Unbuffered, unconsumed results: completion must abandon delivery on
310+ // cancellation instead of wedging shutdown.
311+ results := make(chan appports.RemoteJobResult)
312+ done := make(chan error, 1)
313+ go func() { done <- runtime.Run(ctx, jobs, results) }()
314+
315+ jobs <- appports.RemoteJob{Kind: appports.RemoteJobRegistryRead}
316+ // Let the worker finish and park in completion delivery with no
317+ // consumer on the unbuffered results channel.
318+ time.Sleep(200 * time.Millisecond)
319+ cancel()
320+ select {
321+ case <-done:
322+ case <-time.After(5 * time.Second):
323+ t.Fatal("runtime wedged on unread result")
324+ }
325+}
326+
327+func TestCombineRegistrations(t *testing.T) {
328+ t.Parallel()
329+
330+ archPinned := testRecord("user@arch", 1)
331+ archLearned := testRecord("user@arch", 9)
332+ mule := testRecord("user@mule", 2)
333+ got := combineRegistrations(
334+ []domain.RemoteRegistration{archPinned},
335+ []domain.RemoteRegistration{mule, archLearned},
336+ )
337+ if len(got) != 2 || got[0] != archPinned || got[1] != mule {
338+ t.Fatalf("combine = %+v, want pinned-then-learned-only", got)
339+ }
340+ if len(combineRegistrations(nil, nil)) != 0 {
341+ t.Fatal("empty combine must stay empty")
342+ }
343+}
M internal/adapters/sshstdio/transport.go +28 -0
@@ -526,6 +526,34 @@ func BuildCommandForRemoteCommand(target string, command ...string) CommandSpec
526526 return CommandSpec{Path: "ssh", Args: args}
527527 }
528528
529+// BuildCommandForObservation constructs non-interactive ssh argv for remote
530+// observation (catalogue checks). It reuses the quoting/argv pattern above
531+// and additionally pins batch mode (never prompt), strict host-key checking
532+// (never accept new keys automatically), a bounded connect timeout and a
533+// single connection attempt. TTY allocation is disabled (-T) and advertised
534+// host-key updates are refused (UpdateHostKeys=no) so background observation
535+// can neither grab a terminal nor mutate the user's known-hosts trust state,
536+// even when local ssh configuration requests a TTY or key updates: explicit
537+// command-line options override configuration file values. No stdin is
538+// allocated by this argv; the caller must leave Stdin detached. Interactive
539+// attach authentication is untouched: only observation uses this builder.
540+func BuildCommandForObservation(target string, connectTimeout time.Duration, command ...string) CommandSpec {
541+ secs := int(connectTimeout / time.Second)
542+ if secs < 1 {
543+ secs = 1
544+ }
545+ spec := BuildCommandForRemoteCommand(target, command...)
546+ opts := []string{
547+ "-T",
548+ "-o", "BatchMode=yes",
549+ "-o", "StrictHostKeyChecking=yes",
550+ "-o", "UpdateHostKeys=no",
551+ "-o", fmt.Sprintf("ConnectTimeout=%d", secs),
552+ "-o", "ConnectionAttempts=1",
553+ }
554+ return CommandSpec{Path: spec.Path, Args: append(opts, spec.Args...)}
555+}
556+
529557 // BuildCommandForRemoteLaunch prefixes an explicit complete environment and
530558 // absolute executable to a hidden remote mode. Each value remains one quoted
531559 // remote-shell word; no caller input is concatenated into an unquoted command.
M internal/adapters/sshstdio/transport_test.go +77 -0
@@ -7,6 +7,7 @@ import (
77 "errors"
88 "io"
99 "log/slog"
10+ "os"
1011 "os/exec"
1112 "strings"
1213 "sync"
@@ -109,6 +110,82 @@ func TestBuildCommandForRemoteCommandQuotesEveryWord(t *testing.T) {
109110 }
110111 }
111112
113+func TestBuildCommandForObservationDisablesTTYAndTrustMutation(t *testing.T) {
114+ spec := BuildCommandForObservation("user@example.com", 5*time.Second, "vev", "cmd", "remote-catalog", "--json")
115+ if spec.Path != "ssh" {
116+ t.Fatalf("Path = %q, want ssh", spec.Path)
117+ }
118+ flat := strings.Join(spec.Args, " ")
119+ for _, want := range []string{"-T", "BatchMode=yes", "StrictHostKeyChecking=yes", "UpdateHostKeys=no", "ConnectTimeout=5", "ConnectionAttempts=1"} {
120+ if !strings.Contains(flat, want) {
121+ t.Fatalf("observation argv %q missing %q", spec.Args, want)
122+ }
123+ }
124+
125+ // Effective behavior: explicit CLI options must win over a user
126+ // configuration that requests a TTY, key updates, prompts and lax
127+ // host-key checking. ssh -G reports the effective configuration
128+ // without connecting.
129+ ssh, err := exec.LookPath("ssh")
130+ if err != nil {
131+ t.Skip("ssh binary not available for effective-config probe")
132+ }
133+ conflict := "Host *\n RequestTTY yes\n UpdateHostKeys yes\n BatchMode no\n StrictHostKeyChecking no\n"
134+ confPath := t.TempDir() + "/ssh_config"
135+ if err := os.WriteFile(confPath, []byte(conflict), 0o600); err != nil {
136+ t.Fatalf("write conflicting ssh config: %v", err)
137+ }
138+ sep := -1
139+ for i, arg := range spec.Args {
140+ if arg == "--" {
141+ sep = i
142+ break
143+ }
144+ }
145+ if sep < 0 {
146+ t.Fatalf("observation argv %q has no option terminator", spec.Args)
147+ }
148+ probe := append(append([]string{}, spec.Args[:sep]...), "-F", confPath, "-G", "user@example.com")
149+ out, err := exec.Command(ssh, probe...).Output()
150+ if err != nil {
151+ t.Fatalf("ssh -G probe: %v", err)
152+ }
153+ effective := map[string]string{}
154+ for _, line := range strings.Split(string(out), "\n") {
155+ key, value, ok := strings.Cut(line, " ")
156+ if ok {
157+ effective[strings.ToLower(key)] = strings.ToLower(value)
158+ }
159+ }
160+ for key, want := range map[string]string{
161+ "connecttimeout": "5", "connectionattempts": "1",
162+ } {
163+ if effective[key] != want {
164+ t.Fatalf("effective ssh %s = %q, want %q (config must not override observation argv)", key, effective[key], want)
165+ }
166+ }
167+ // ssh -G spells boolean keywords true/false where the configuration
168+ // spells yes/no; normalize both spellings before comparing.
169+ normalize := func(value string) string {
170+ switch value {
171+ case "yes", "true":
172+ return "yes"
173+ case "no", "false":
174+ return "no"
175+ default:
176+ return value
177+ }
178+ }
179+ for key, want := range map[string]string{
180+ "batchmode": "yes", "stricthostkeychecking": "yes",
181+ "requesttty": "no", "updatehostkeys": "no",
182+ } {
183+ if got := normalize(effective[key]); got != want {
184+ t.Fatalf("effective ssh %s = %q, want %q (config must not override observation argv)", key, effective[key], want)
185+ }
186+ }
187+}
188+
112189 func TestCloseInterruptsBlockedSend(t *testing.T) {
113190 writer := &blockedWriter{started: make(chan struct{}), released: make(chan struct{})}
114191 transport := NewTransport(nil, writer, nil)
M internal/app/launch_allowlist.go +12 -2
@@ -40,9 +40,9 @@ type allowlistedRemoteHostStore struct {
4040 allowed map[string]struct{}
4141 }
4242
43-func (s allowlistedRemoteHostStore) Hosts() (pinned, learned []string, err error) {
43+func (s allowlistedRemoteHostStore) Hosts() (pinned, learned []domain.RemoteRegistration, err error) {
4444 pinned, learned, err = s.delegate.Hosts()
45- return filterRemoteTargets(pinned, s.allowed), filterRemoteTargets(learned, s.allowed), err
45+ return filterRemoteRegistrations(pinned, s.allowed), filterRemoteRegistrations(learned, s.allowed), err
4646 }
4747
4848 func (s allowlistedRemoteHostStore) AddPinned(target string) error {
@@ -143,6 +143,16 @@ func filterRemoteTargets(targets []string, allowed map[string]struct{}) []string
143143 return filtered
144144 }
145145
146+func filterRemoteRegistrations(registrations []domain.RemoteRegistration, allowed map[string]struct{}) []domain.RemoteRegistration {
147+ filtered := make([]domain.RemoteRegistration, 0, len(registrations))
148+ for _, record := range registrations {
149+ if allowlistedRemoteEndpoint(allowed, record.Endpoint) {
150+ filtered = append(filtered, record)
151+ }
152+ }
153+ return filtered
154+}
155+
146156 func endpointNotConfiguredError() error {
147157 return &ports.UIError{Code: ports.UIErrEndpointNotConfigured}
148158 }
M internal/app/remote_hosts.go +17 -17
@@ -232,29 +232,29 @@ func mergeKnownHosts(deps remoteHostDeps) ([]domain.RemoteHost, error) {
232232
233233 // mergeRemoteHosts returns pinned hosts in stored order, then learned-only
234234 // hosts in lexical order. Duplicates keep first occurrence and mark both sources.
235-func mergeRemoteHosts(pinned, learned []string) []domain.RemoteHost {
236- pinned = domain.UniqueRemoteHostTargets(pinned)
235+func mergeRemoteHosts(pinned, learned []domain.RemoteRegistration) []domain.RemoteHost {
236+ pinned = domain.UniqueRemoteRegistrations(pinned)
237237 learnedSet := make(map[string]struct{}, len(learned))
238- for _, target := range learned {
239- learnedSet[target] = struct{}{}
238+ for _, record := range learned {
239+ learnedSet[record.Endpoint] = struct{}{}
240240 }
241241 pinnedSet := make(map[string]struct{}, len(pinned))
242242 out := make([]domain.RemoteHost, 0, len(pinned)+len(learned))
243- for _, target := range pinned {
244- pinnedSet[target] = struct{}{}
245- _, isLearned := learnedSet[target]
246- out = append(out, domain.RemoteHost{Target: target, Pinned: true, Learned: isLearned})
247- }
248- learnedOnly := make([]string, 0, len(learned))
249- for _, target := range learned {
250- if _, ok := pinnedSet[target]; !ok {
251- learnedOnly = append(learnedOnly, target)
243+ for _, record := range pinned {
244+ pinnedSet[record.Endpoint] = struct{}{}
245+ _, isLearned := learnedSet[record.Endpoint]
246+ out = append(out, domain.RemoteHost{Target: record.Endpoint, Pinned: true, Learned: isLearned, Registration: record})
247+ }
248+ learnedOnly := make([]domain.RemoteRegistration, 0, len(learned))
249+ for _, record := range learned {
250+ if _, ok := pinnedSet[record.Endpoint]; !ok {
251+ learnedOnly = append(learnedOnly, record)
252252 }
253253 }
254- learnedOnly = domain.UniqueRemoteHostTargets(learnedOnly)
255- sort.Strings(learnedOnly)
256- for _, target := range learnedOnly {
257- out = append(out, domain.RemoteHost{Target: target, Learned: true})
254+ learnedOnly = domain.UniqueRemoteRegistrations(learnedOnly)
255+ sort.Slice(learnedOnly, func(i, j int) bool { return learnedOnly[i].Endpoint < learnedOnly[j].Endpoint })
256+ for _, record := range learnedOnly {
257+ out = append(out, domain.RemoteHost{Target: record.Endpoint, Learned: true, Registration: record})
258258 }
259259 return out
260260 }
M internal/app/remote_test.go +26 -7
@@ -72,13 +72,32 @@ func TestDecodeSessionListErrorReply(t *testing.T) {
7272 })
7373 }
7474
75+func testRemoteRegistrations(t *testing.T, endpoints ...string) []domain.RemoteRegistration {
76+ t.Helper()
77+ out := make([]domain.RemoteRegistration, 0, len(endpoints))
78+ for i, endpoint := range endpoints {
79+ var incarnation [16]byte
80+ incarnation[0] = byte(i + 1)
81+ out = append(out, domain.RemoteRegistration{Endpoint: endpoint, Incarnation: incarnation, Generation: 1})
82+ }
83+ return out
84+}
85+
7586 func TestMergeRemoteHostsOrderAndSource(t *testing.T) {
76- got := mergeRemoteHosts([]string{"zebra", "arch"}, []string{"mule", "arch", "beta"})
87+ zebra := testRemoteRegistrations(t, "zebra")[0]
88+ archPinned := testRemoteRegistrations(t, "arch")[0]
89+ archLearned := testRemoteRegistrations(t, "x", "arch")[1]
90+ beta := testRemoteRegistrations(t, "beta")[0]
91+ mule := testRemoteRegistrations(t, "mule")[0]
92+ got := mergeRemoteHosts(
93+ []domain.RemoteRegistration{zebra, archPinned},
94+ []domain.RemoteRegistration{mule, archLearned, beta},
95+ )
7796 require.Equal(t, []domain.RemoteHost{
78- {Target: "zebra", Pinned: true},
79- {Target: "arch", Pinned: true, Learned: true},
80- {Target: "beta", Learned: true},
81- {Target: "mule", Learned: true},
97+ {Target: "zebra", Pinned: true, Registration: zebra},
98+ {Target: "arch", Pinned: true, Learned: true, Registration: archPinned},
99+ {Target: "beta", Learned: true, Registration: beta},
100+ {Target: "mule", Learned: true, Registration: mule},
82101 }, got)
83102 }
84103
@@ -135,7 +154,7 @@ func TestRemoteHostCommands(t *testing.T) {
135154 name: "list preserves source markers and order",
136155 run: func(t *testing.T) {
137156 store := portsmocks.NewMockRemoteHostStore(t)
138- store.EXPECT().Hosts().Return([]string{"zebra", "arch"}, []string{"mule", "arch", "beta"}, nil).Once()
157+ store.EXPECT().Hosts().Return(testRemoteRegistrations(t, "zebra", "arch"), testRemoteRegistrations(t, "mule", "arch", "beta"), nil).Once()
139158 var out bytes.Buffer
140159 err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, &out))
141160 require.NoError(t, err)
@@ -173,7 +192,7 @@ func TestRemoteHostCommands(t *testing.T) {
173192
174193 func TestRemoteHostListingUsesUnifiedStore(t *testing.T) {
175194 store := portsmocks.NewMockRemoteHostStore(t)
176- store.EXPECT().Hosts().Return([]string{"arch"}, []string{"beta"}, nil).Once()
195+ store.EXPECT().Hosts().Return(testRemoteRegistrations(t, "arch"), testRemoteRegistrations(t, "beta"), nil).Once()
177196 catalog := portsmocks.NewMockRemoteCatalogClient(t)
178197 catalog.EXPECT().List(mock.Anything, "arch").Return(catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "build", State: "up"}}}, nil).Once()
179198 var out bytes.Buffer
M internal/app/run.go +10 -6
@@ -51,6 +51,7 @@ import (
5151 "github.com/bnema/vev/internal/usecase/client"
5252 "github.com/bnema/vev/internal/usecase/daemon"
5353 "github.com/bnema/vev/internal/usecase/recovery"
54+ "github.com/bnema/vev/internal/usecase/remotes"
5455 pdgram "github.com/bnema/vev/pkg/dgram"
5556 "github.com/bnema/vev/pkg/safedir"
5657 )
@@ -706,9 +707,9 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err
706707 }
707708 var remoteDiscoveryOpt daemon.Option
708709 if allowlistConfigured {
709- remoteDiscoveryOpt, err = remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport), allowedRemoteEndpoints)
710+ remoteDiscoveryOpt, err = remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport), clk, log, allowedRemoteEndpoints)
710711 } else {
711- remoteDiscoveryOpt, err = remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport))
712+ remoteDiscoveryOpt, err = remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport), clk, log)
712713 }
713714 if err != nil {
714715 return err
@@ -925,8 +926,10 @@ func validateRemoteAttachHandoff(target protocol.AttachTarget) error {
925926 }
926927
927928 // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from
928-// the same validated transport selection used by direct remote attach.
929-func remoteDiscoveryDaemonOption(stateDir, transport string, allowlists ...map[string]struct{}) (daemon.Option, error) {
929+// the same validated transport selection used by direct remote attach, then
930+// composes the bounded remote runtime and its monitor. Construction performs
931+// no I/O; the daemon starts the monitor without waiting for readiness.
932+func remoteDiscoveryDaemonOption(stateDir, transport string, clk ports.Clock, log *slog.Logger, allowlists ...map[string]struct{}) (daemon.Option, error) {
930933 _, err := remoteTransportModeFromEnv(transport)
931934 if err != nil {
932935 return nil, err
@@ -942,11 +945,12 @@ func remoteDiscoveryDaemonOption(stateDir, transport string, allowlists ...map[s
942945 cache = allowlistedRemoteCatalogCache{delegate: cache, allowed: allowed}
943946 previewClient = allowlistedRemotePreviewClient{delegate: previewClient, allowed: allowed}
944947 }
945- discovery := daemon.WithRemoteDiscovery(store, catalog, cache)
946948 preview := daemon.WithRemotePreview(previewClient)
949+ monitor := remotes.NewMonitor(remoteadapter.NewRuntime(store, catalog, cache, log), clk, log)
950+ remoteMonitor := daemon.WithRemoteMonitor(monitor, monitor.Run)
947951 return func(d *daemon.Daemon) {
948- discovery(d)
949952 preview(d)
953+ remoteMonitor(d)
950954 }, nil
951955 }
952956
M internal/app/run_test.go +4 -3
@@ -17,6 +17,7 @@ import (
1717 "testing"
1818 "time"
1919
20+ "github.com/bnema/vev/internal/adapters/clock"
2021 "github.com/bnema/vev/internal/adapters/lifecycle"
2122 remoteadapter "github.com/bnema/vev/internal/adapters/remote"
2223 "github.com/bnema/vev/internal/adapters/sessionwire"
@@ -1418,15 +1419,15 @@ func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) {
14181419 newRemoteCatalogClient = func() ports.RemoteCatalogClient { return nil }
14191420 newRemoteDialerFactoryWithRuntimeObserver = func(ports.SerializedRuntimeObserver) remoteDialerForTarget { return nil }
14201421
1421- option, err := remoteDiscoveryDaemonOption(stateDir, "stdio")
1422+ option, err := remoteDiscoveryDaemonOption(stateDir, "stdio", clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil)))
14221423 require.NoError(t, err)
14231424 _ = daemon.New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), option)
14241425 require.Equal(t, remoteadapter.HostStorePath(stateDir), hostPath)
14251426 require.Equal(t, remoteadapter.CatalogCachePath(stateDir), cachePath)
1426- require.Equal(t, 1, cache.loads, "daemon startup must load the cache once")
1427+ require.Equal(t, 0, cache.loads, "daemon constructors must stay I/O-free; the cache warms asynchronously in Serve")
14271428 }
14281429
14291430 func TestRemoteDiscoveryDaemonOptionRejectsInvalidTransport(t *testing.T) {
1430- _, err := remoteDiscoveryDaemonOption(t.TempDir(), "serial")
1431+ _, err := remoteDiscoveryDaemonOption(t.TempDir(), "serial", clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil)))
14311432 require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`)
14321433 }
M internal/domain/remote_host.go +19 -3
@@ -8,9 +8,10 @@ import (
88
99 // RemoteHost is a merged remote host entry with its source markers.
1010 type RemoteHost struct {
11- Target string
12- Pinned bool
13- Learned bool
11+ Target string
12+ Pinned bool
13+ Learned bool
14+ Registration RemoteRegistration
1415 }
1516
1617 // ValidateRemoteHostTarget rejects empty targets, surrounding/internal whitespace,
@@ -43,3 +44,18 @@ func UniqueRemoteHostTargets(values []string) []string {
4344 }
4445 return out
4546 }
47+
48+// UniqueRemoteRegistrations returns a de-duplicated copy preserving first
49+// occurrence order by endpoint.
50+func UniqueRemoteRegistrations(values []RemoteRegistration) []RemoteRegistration {
51+ seen := make(map[string]struct{}, len(values))
52+ out := make([]RemoteRegistration, 0, len(values))
53+ for _, value := range values {
54+ if _, ok := seen[value.Endpoint]; ok {
55+ continue
56+ }
57+ seen[value.Endpoint] = struct{}{}
58+ out = append(out, value)
59+ }
60+ return out
61+}
A internal/domain/remote_state.go +158 -0
@@ -0,0 +1,158 @@
1+package domain
2+
3+import (
4+ "fmt"
5+)
6+
7+// RemoteAvailability is the monitor's last-known reachability for one
8+// registered endpoint. Checking is tracked separately and never replaces
9+// availability: an in-flight observation preserves the previous value.
10+type RemoteAvailability uint8
11+
12+const (
13+ RemoteAvailabilityUnknown RemoteAvailability = iota + 1
14+ RemoteAvailabilityReachable
15+ RemoteAvailabilityUnreachable
16+ RemoteAvailabilityIncompatible
17+ RemoteAvailabilityAuthFailed
18+ RemoteAvailabilityInvalidResponse
19+)
20+
21+// String returns the stable lowercase wire/display token for an availability.
22+func (a RemoteAvailability) String() string {
23+ switch a {
24+ case RemoteAvailabilityUnknown:
25+ return "unknown"
26+ case RemoteAvailabilityReachable:
27+ return "reachable"
28+ case RemoteAvailabilityUnreachable:
29+ return "unreachable"
30+ case RemoteAvailabilityIncompatible:
31+ return "incompatible"
32+ case RemoteAvailabilityAuthFailed:
33+ return "authentication_failed"
34+ case RemoteAvailabilityInvalidResponse:
35+ return "invalid_response"
36+ default:
37+ return "unknown"
38+ }
39+}
40+
41+// RemoteFailureKind classifies why one remote observation failed. It mirrors
42+// the observation error classes without carrying raw stderr or endpoints.
43+type RemoteFailureKind uint8
44+
45+const (
46+ RemoteFailureNone RemoteFailureKind = iota
47+ RemoteFailureTransport
48+ RemoteFailureTimeout
49+ RemoteFailureTrust
50+ RemoteFailureAuthentication
51+ RemoteFailureIncompatible
52+ RemoteFailureInvalidResponse
53+)
54+
55+// String returns the stable lowercase token for a failure kind.
56+func (k RemoteFailureKind) String() string {
57+ switch k {
58+ case RemoteFailureNone:
59+ return "none"
60+ case RemoteFailureTransport:
61+ return "transport"
62+ case RemoteFailureTimeout:
63+ return "timeout"
64+ case RemoteFailureTrust:
65+ return "trust"
66+ case RemoteFailureAuthentication:
67+ return "authentication"
68+ case RemoteFailureIncompatible:
69+ return "incompatible"
70+ case RemoteFailureInvalidResponse:
71+ return "invalid_response"
72+ default:
73+ return "transport"
74+ }
75+}
76+
77+// RemoteFailure is the typed last-failure value carried in snapshots. Err
78+// preserves the underlying cause for errors.Is/As callers; Kind is the
79+// sanitized classification safe for presentation and notice policy.
80+type RemoteFailure struct {
81+ Kind RemoteFailureKind
82+ Err error
83+}
84+
85+// Error reports the sanitized failure kind.
86+func (f RemoteFailure) Error() string { return "remote observation failed: " + f.Kind.String() }
87+
88+// Unwrap exposes the underlying cause to errors.Is/As.
89+func (f RemoteFailure) Unwrap() error { return f.Err }
90+
91+// RemoteGeneration is a per-registration fencing generation. It increments
92+// each time an endpoint is (re-)registered so late results bound to an older
93+// generation can never apply to a newer registration of the same endpoint.
94+type RemoteGeneration uint64
95+
96+// RemoteRegistration is the exact configured identity of one monitored
97+// endpoint: the endpoint string plus a persistent random incarnation. Full
98+// removal followed by re-add creates a new incarnation, so equality on both
99+// fields fences ABA re-registration even when the endpoint string matches.
100+type RemoteRegistration struct {
101+ Endpoint string
102+ Incarnation [16]byte
103+ Generation RemoteGeneration
104+}
105+
106+// NewRemoteRegistration creates a registration for a validated endpoint
107+// with an explicit incarnation. Randomness is the caller's concern:
108+// adapters sample fresh incarnations while domain stays deterministic.
109+func NewRemoteRegistration(endpoint string, incarnation [16]byte) (RemoteRegistration, error) {
110+ if err := ValidateRemoteHostTarget(endpoint); err != nil {
111+ return RemoteRegistration{}, err
112+ }
113+ return RemoteRegistration{Endpoint: endpoint, Incarnation: incarnation, Generation: 1}, nil
114+}
115+
116+// IsZero reports whether the registration carries no identity. A zero
117+// incarnation never identifies a host; unbound legacy records stay zero.
118+func (r RemoteRegistration) IsZero() bool {
119+ return r.Endpoint == "" || r.Incarnation == [16]byte{}
120+}
121+
122+// Equal compares exact configured identity: endpoint, incarnation and
123+// generation must all match.
124+func (r RemoteRegistration) Equal(other RemoteRegistration) bool {
125+ return r.Endpoint == other.Endpoint && r.Incarnation == other.Incarnation && r.Generation == other.Generation
126+}
127+
128+// Validate rejects empty endpoints and zero incarnations.
129+func (r RemoteRegistration) Validate() error {
130+ if err := ValidateRemoteHostTarget(r.Endpoint); err != nil {
131+ return err
132+ }
133+ if r.Incarnation == [16]byte{} {
134+ return fmt.Errorf("remote registration for %q has no incarnation", r.Endpoint)
135+ }
136+ if r.Generation == 0 {
137+ return fmt.Errorf("remote registration for %q has no generation", r.Endpoint)
138+ }
139+ return nil
140+}
141+
142+// Stable RemoteReason tokens shared by catalogue validation, picker
143+// presentation, and daemon projections. Centralizing them here keeps
144+// display/diagnostic labels consistent without magic strings.
145+const (
146+ RemoteReasonRefreshing = "refreshing"
147+ RemoteReasonCatalogStale = "catalog_stale"
148+ RemoteReasonHostUnreachable = "host_unreachable"
149+ RemoteReasonVersionMismatch = "version_mismatch"
150+ RemoteReasonSessionDown = "session_down"
151+ RemoteReasonSessionBroken = "session_broken"
152+ RemoteReasonSessionStopped = "session_stopped"
153+ RemoteReasonIdentityChanged = "identity_changed"
154+ RemoteReasonNotFound = "not_found"
155+ RemoteReasonTimeout = "timeout"
156+ RemoteReasonMalformed = "malformed"
157+ RemoteReasonAuthFailure = "auth_failure"
158+)
A internal/domain/remote_state_test.go +135 -0
@@ -0,0 +1,135 @@
1+package domain
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "testing"
7+)
8+
9+func TestRemoteAvailabilityString(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ value RemoteAvailability
13+ want string
14+ }{
15+ {name: "unknown", value: RemoteAvailabilityUnknown, want: "unknown"},
16+ {name: "reachable", value: RemoteAvailabilityReachable, want: "reachable"},
17+ {name: "unreachable", value: RemoteAvailabilityUnreachable, want: "unreachable"},
18+ {name: "incompatible", value: RemoteAvailabilityIncompatible, want: "incompatible"},
19+ {name: "auth failed", value: RemoteAvailabilityAuthFailed, want: "authentication_failed"},
20+ {name: "invalid response", value: RemoteAvailabilityInvalidResponse, want: "invalid_response"},
21+ {name: "zero maps to unknown", value: RemoteAvailability(0), want: "unknown"},
22+ {name: "out of range maps to unknown", value: RemoteAvailability(99), want: "unknown"},
23+ }
24+ for _, test := range tests {
25+ t.Run(test.name, func(t *testing.T) {
26+ if got := test.value.String(); got != test.want {
27+ t.Fatalf("String() = %q, want %q", got, test.want)
28+ }
29+ })
30+ }
31+}
32+
33+func TestRemoteFailureKindString(t *testing.T) {
34+ tests := []struct {
35+ name string
36+ value RemoteFailureKind
37+ want string
38+ }{
39+ {name: "none", value: RemoteFailureNone, want: "none"},
40+ {name: "transport", value: RemoteFailureTransport, want: "transport"},
41+ {name: "timeout", value: RemoteFailureTimeout, want: "timeout"},
42+ {name: "trust", value: RemoteFailureTrust, want: "trust"},
43+ {name: "authentication", value: RemoteFailureAuthentication, want: "authentication"},
44+ {name: "incompatible", value: RemoteFailureIncompatible, want: "incompatible"},
45+ {name: "invalid response", value: RemoteFailureInvalidResponse, want: "invalid_response"},
46+ {name: "out of range stays transport", value: RemoteFailureKind(99), want: "transport"},
47+ }
48+ for _, test := range tests {
49+ t.Run(test.name, func(t *testing.T) {
50+ if got := test.value.String(); got != test.want {
51+ t.Fatalf("String() = %q, want %q", got, test.want)
52+ }
53+ })
54+ }
55+}
56+
57+func TestRemoteFailurePreservesCause(t *testing.T) {
58+ cause := errors.New("ssh: connection refused")
59+ failure := RemoteFailure{Kind: RemoteFailureTimeout, Err: cause}
60+ if !errors.Is(failure, cause) {
61+ t.Fatalf("errors.Is(failure, cause) = false, want true")
62+ }
63+ var asFailure RemoteFailure
64+ if !errors.As(fmt.Errorf("observe: %w", failure), &asFailure) || asFailure.Kind != RemoteFailureTimeout {
65+ t.Fatalf("errors.As did not recover %+v", failure)
66+ }
67+ if failure.Error() == "" || failure.Error() == cause.Error() {
68+ t.Fatalf("Error() must report the sanitized kind, got %q", failure.Error())
69+ }
70+}
71+
72+func TestNewRemoteRegistration(t *testing.T) {
73+ first, err := NewRemoteRegistration("user@arch", [16]byte{1})
74+ if err != nil {
75+ t.Fatalf("NewRemoteRegistration: %v", err)
76+ }
77+ if err := first.Validate(); err != nil {
78+ t.Fatalf("Validate: %v", err)
79+ }
80+ if first.IsZero() {
81+ t.Fatal("fresh registration must not be zero")
82+ }
83+ second, err := NewRemoteRegistration("user@arch", [16]byte{2})
84+ if err != nil {
85+ t.Fatalf("NewRemoteRegistration: %v", err)
86+ }
87+ if first.Incarnation == second.Incarnation {
88+ t.Fatal("incarnations must be unique per registration")
89+ }
90+ if first.Equal(second) {
91+ t.Fatal("distinct incarnations must not compare equal")
92+ }
93+ if _, err := NewRemoteRegistration("not a host", [16]byte{1}); err == nil {
94+ t.Fatal("invalid endpoint must be rejected")
95+ }
96+}
97+
98+func TestRemoteRegistrationIdentity(t *testing.T) {
99+ valid := RemoteRegistration{Endpoint: "user@arch", Incarnation: [16]byte{1}, Generation: 2}
100+ tests := []struct {
101+ name string
102+ value RemoteRegistration
103+ zero bool
104+ validate bool
105+ }{
106+ {name: "valid", value: valid, zero: false, validate: true},
107+ {name: "empty", value: RemoteRegistration{}, zero: true, validate: false},
108+ {name: "zero incarnation", value: RemoteRegistration{Endpoint: "user@arch", Generation: 1}, zero: true, validate: false},
109+ {name: "zero generation", value: RemoteRegistration{Endpoint: "user@arch", Incarnation: [16]byte{1}}, zero: false, validate: false},
110+ {name: "bad endpoint", value: RemoteRegistration{Endpoint: "not a host", Incarnation: [16]byte{1}, Generation: 1}, zero: false, validate: false},
111+ }
112+ for _, test := range tests {
113+ t.Run(test.name, func(t *testing.T) {
114+ if got := test.value.IsZero(); got != test.zero {
115+ t.Fatalf("IsZero() = %t, want %t", got, test.zero)
116+ }
117+ if err := test.value.Validate(); (err == nil) != test.validate {
118+ t.Fatalf("Validate() err = %v, want valid=%t", err, test.validate)
119+ }
120+ })
121+ }
122+ if !valid.Equal(valid) {
123+ t.Fatal("registration must equal itself")
124+ }
125+ renamed := valid
126+ renamed.Endpoint = "user@mule"
127+ if valid.Equal(renamed) {
128+ t.Fatal("different endpoints must not compare equal")
129+ }
130+ advanced := valid
131+ advanced.Generation++
132+ if valid.Equal(advanced) {
133+ t.Fatal("different generations must not compare equal")
134+ }
135+}
M internal/ports/mocks/remote_mock.go +10 -10
@@ -143,31 +143,31 @@ func (_c *MockRemoteHostStore_Forget_Call) RunAndReturn(run func(target string)
143143 }
144144
145145 // Hosts provides a mock function for the type MockRemoteHostStore
146-func (_mock *MockRemoteHostStore) Hosts() ([]string, []string, error) {
146+func (_mock *MockRemoteHostStore) Hosts() ([]domain.RemoteRegistration, []domain.RemoteRegistration, error) {
147147 ret := _mock.Called()
148148
149149 if len(ret) == 0 {
150150 panic("no return value specified for Hosts")
151151 }
152152
153- var r0 []string
154- var r1 []string
153+ var r0 []domain.RemoteRegistration
154+ var r1 []domain.RemoteRegistration
155155 var r2 error
156- if returnFunc, ok := ret.Get(0).(func() ([]string, []string, error)); ok {
156+ if returnFunc, ok := ret.Get(0).(func() ([]domain.RemoteRegistration, []domain.RemoteRegistration, error)); ok {
157157 return returnFunc()
158158 }
159- if returnFunc, ok := ret.Get(0).(func() []string); ok {
159+ if returnFunc, ok := ret.Get(0).(func() []domain.RemoteRegistration); ok {
160160 r0 = returnFunc()
161161 } else {
162162 if ret.Get(0) != nil {
163- r0 = ret.Get(0).([]string)
163+ r0 = ret.Get(0).([]domain.RemoteRegistration)
164164 }
165165 }
166- if returnFunc, ok := ret.Get(1).(func() []string); ok {
166+ if returnFunc, ok := ret.Get(1).(func() []domain.RemoteRegistration); ok {
167167 r1 = returnFunc()
168168 } else {
169169 if ret.Get(1) != nil {
170- r1 = ret.Get(1).([]string)
170+ r1 = ret.Get(1).([]domain.RemoteRegistration)
171171 }
172172 }
173173 if returnFunc, ok := ret.Get(2).(func() error); ok {
@@ -195,12 +195,12 @@ func (_c *MockRemoteHostStore_Hosts_Call) Run(run func()) *MockRemoteHostStore_H
195195 return _c
196196 }
197197
198-func (_c *MockRemoteHostStore_Hosts_Call) Return(pinned []string, learned []string, err error) *MockRemoteHostStore_Hosts_Call {
198+func (_c *MockRemoteHostStore_Hosts_Call) Return(pinned []domain.RemoteRegistration, learned []domain.RemoteRegistration, err error) *MockRemoteHostStore_Hosts_Call {
199199 _c.Call.Return(pinned, learned, err)
200200 return _c
201201 }
202202
203-func (_c *MockRemoteHostStore_Hosts_Call) RunAndReturn(run func() ([]string, []string, error)) *MockRemoteHostStore_Hosts_Call {
203+func (_c *MockRemoteHostStore_Hosts_Call) RunAndReturn(run func() ([]domain.RemoteRegistration, []domain.RemoteRegistration, error)) *MockRemoteHostStore_Hosts_Call {
204204 _c.Call.Return(run)
205205 return _c
206206 }
A internal/ports/mocks/remote_runtime_mock.go +102 -0
@@ -0,0 +1,102 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "context"
9+
10+ "github.com/bnema/vev/internal/ports"
11+ mock "github.com/stretchr/testify/mock"
12+)
13+
14+// NewMockRemoteRuntime creates a new instance of MockRemoteRuntime. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
15+// The first argument is typically a *testing.T value.
16+func NewMockRemoteRuntime(t interface {
17+ mock.TestingT
18+ Cleanup(func())
19+}) *MockRemoteRuntime {
20+ mock := &MockRemoteRuntime{}
21+ mock.Mock.Test(t)
22+
23+ t.Cleanup(func() { mock.AssertExpectations(t) })
24+
25+ return mock
26+}
27+
28+// MockRemoteRuntime is an autogenerated mock type for the RemoteRuntime type
29+type MockRemoteRuntime struct {
30+ mock.Mock
31+}
32+
33+type MockRemoteRuntime_Expecter struct {
34+ mock *mock.Mock
35+}
36+
37+func (_m *MockRemoteRuntime) EXPECT() *MockRemoteRuntime_Expecter {
38+ return &MockRemoteRuntime_Expecter{mock: &_m.Mock}
39+}
40+
41+// Run provides a mock function for the type MockRemoteRuntime
42+func (_mock *MockRemoteRuntime) Run(ctx context.Context, jobs <-chan ports.RemoteJob, results chan<- ports.RemoteJobResult) error {
43+ ret := _mock.Called(ctx, jobs, results)
44+
45+ if len(ret) == 0 {
46+ panic("no return value specified for Run")
47+ }
48+
49+ var r0 error
50+ if returnFunc, ok := ret.Get(0).(func(context.Context, <-chan ports.RemoteJob, chan<- ports.RemoteJobResult) error); ok {
51+ r0 = returnFunc(ctx, jobs, results)
52+ } else {
53+ r0 = ret.Error(0)
54+ }
55+ return r0
56+}
57+
58+// MockRemoteRuntime_Run_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Run'
59+type MockRemoteRuntime_Run_Call struct {
60+ *mock.Call
61+}
62+
63+// Run is a helper method to define mock.On call
64+// - ctx context.Context
65+// - jobs <-chan ports.RemoteJob
66+// - results chan<- ports.RemoteJobResult
67+func (_e *MockRemoteRuntime_Expecter) Run(ctx any, jobs any, results any) *MockRemoteRuntime_Run_Call {
68+ return &MockRemoteRuntime_Run_Call{Call: _e.mock.On("Run", ctx, jobs, results)}
69+}
70+
71+func (_c *MockRemoteRuntime_Run_Call) Run(run func(ctx context.Context, jobs <-chan ports.RemoteJob, results chan<- ports.RemoteJobResult)) *MockRemoteRuntime_Run_Call {
72+ _c.Call.Run(func(args mock.Arguments) {
73+ var arg0 context.Context
74+ if args[0] != nil {
75+ arg0 = args[0].(context.Context)
76+ }
77+ var arg1 <-chan ports.RemoteJob
78+ if args[1] != nil {
79+ arg1 = args[1].(<-chan ports.RemoteJob)
80+ }
81+ var arg2 chan<- ports.RemoteJobResult
82+ if args[2] != nil {
83+ arg2 = args[2].(chan<- ports.RemoteJobResult)
84+ }
85+ run(
86+ arg0,
87+ arg1,
88+ arg2,
89+ )
90+ })
91+ return _c
92+}
93+
94+func (_c *MockRemoteRuntime_Run_Call) Return(err error) *MockRemoteRuntime_Run_Call {
95+ _c.Call.Return(err)
96+ return _c
97+}
98+
99+func (_c *MockRemoteRuntime_Run_Call) RunAndReturn(run func(ctx context.Context, jobs <-chan ports.RemoteJob, results chan<- ports.RemoteJobResult) error) *MockRemoteRuntime_Run_Call {
100+ _c.Call.Return(run)
101+ return _c
102+}
A internal/ports/mocks/remote_state_mock.go +306 -0
@@ -0,0 +1,306 @@
1+// Code generated by mockery; DO NOT EDIT.
2+// github.com/vektra/mockery
3+// template: testify
4+
5+package portsmocks
6+
7+import (
8+ "github.com/bnema/vev/internal/ports"
9+ mock "github.com/stretchr/testify/mock"
10+)
11+
12+// NewMockRemoteDirectorySubscription creates a new instance of MockRemoteDirectorySubscription. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
13+// The first argument is typically a *testing.T value.
14+func NewMockRemoteDirectorySubscription(t interface {
15+ mock.TestingT
16+ Cleanup(func())
17+}) *MockRemoteDirectorySubscription {
18+ mock := &MockRemoteDirectorySubscription{}
19+ mock.Mock.Test(t)
20+
21+ t.Cleanup(func() { mock.AssertExpectations(t) })
22+
23+ return mock
24+}
25+
26+// MockRemoteDirectorySubscription is an autogenerated mock type for the RemoteDirectorySubscription type
27+type MockRemoteDirectorySubscription struct {
28+ mock.Mock
29+}
30+
31+type MockRemoteDirectorySubscription_Expecter struct {
32+ mock *mock.Mock
33+}
34+
35+func (_m *MockRemoteDirectorySubscription) EXPECT() *MockRemoteDirectorySubscription_Expecter {
36+ return &MockRemoteDirectorySubscription_Expecter{mock: &_m.Mock}
37+}
38+
39+// Changed provides a mock function for the type MockRemoteDirectorySubscription
40+func (_mock *MockRemoteDirectorySubscription) Changed() <-chan struct{} {
41+ ret := _mock.Called()
42+
43+ if len(ret) == 0 {
44+ panic("no return value specified for Changed")
45+ }
46+
47+ var r0 <-chan struct{}
48+ if returnFunc, ok := ret.Get(0).(func() <-chan struct{}); ok {
49+ r0 = returnFunc()
50+ } else {
51+ if ret.Get(0) != nil {
52+ r0 = ret.Get(0).(<-chan struct{})
53+ }
54+ }
55+ return r0
56+}
57+
58+// MockRemoteDirectorySubscription_Changed_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Changed'
59+type MockRemoteDirectorySubscription_Changed_Call struct {
60+ *mock.Call
61+}
62+
63+// Changed is a helper method to define mock.On call
64+func (_e *MockRemoteDirectorySubscription_Expecter) Changed() *MockRemoteDirectorySubscription_Changed_Call {
65+ return &MockRemoteDirectorySubscription_Changed_Call{Call: _e.mock.On("Changed")}
66+}
67+
68+func (_c *MockRemoteDirectorySubscription_Changed_Call) Run(run func()) *MockRemoteDirectorySubscription_Changed_Call {
69+ _c.Call.Run(func(args mock.Arguments) {
70+ run()
71+ })
72+ return _c
73+}
74+
75+func (_c *MockRemoteDirectorySubscription_Changed_Call) Return(valCh <-chan struct{}) *MockRemoteDirectorySubscription_Changed_Call {
76+ _c.Call.Return(valCh)
77+ return _c
78+}
79+
80+func (_c *MockRemoteDirectorySubscription_Changed_Call) RunAndReturn(run func() <-chan struct{}) *MockRemoteDirectorySubscription_Changed_Call {
81+ _c.Call.Return(run)
82+ return _c
83+}
84+
85+// Close provides a mock function for the type MockRemoteDirectorySubscription
86+func (_mock *MockRemoteDirectorySubscription) Close() {
87+ _mock.Called()
88+ return
89+}
90+
91+// MockRemoteDirectorySubscription_Close_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Close'
92+type MockRemoteDirectorySubscription_Close_Call struct {
93+ *mock.Call
94+}
95+
96+// Close is a helper method to define mock.On call
97+func (_e *MockRemoteDirectorySubscription_Expecter) Close() *MockRemoteDirectorySubscription_Close_Call {
98+ return &MockRemoteDirectorySubscription_Close_Call{Call: _e.mock.On("Close")}
99+}
100+
101+func (_c *MockRemoteDirectorySubscription_Close_Call) Run(run func()) *MockRemoteDirectorySubscription_Close_Call {
102+ _c.Call.Run(func(args mock.Arguments) {
103+ run()
104+ })
105+ return _c
106+}
107+
108+func (_c *MockRemoteDirectorySubscription_Close_Call) Return() *MockRemoteDirectorySubscription_Close_Call {
109+ _c.Call.Return()
110+ return _c
111+}
112+
113+func (_c *MockRemoteDirectorySubscription_Close_Call) RunAndReturn(run func()) *MockRemoteDirectorySubscription_Close_Call {
114+ _c.Run(run)
115+ return _c
116+}
117+
118+// NewMockRemoteDirectory creates a new instance of MockRemoteDirectory. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
119+// The first argument is typically a *testing.T value.
120+func NewMockRemoteDirectory(t interface {
121+ mock.TestingT
122+ Cleanup(func())
123+}) *MockRemoteDirectory {
124+ mock := &MockRemoteDirectory{}
125+ mock.Mock.Test(t)
126+
127+ t.Cleanup(func() { mock.AssertExpectations(t) })
128+
129+ return mock
130+}
131+
132+// MockRemoteDirectory is an autogenerated mock type for the RemoteDirectory type
133+type MockRemoteDirectory struct {
134+ mock.Mock
135+}
136+
137+type MockRemoteDirectory_Expecter struct {
138+ mock *mock.Mock
139+}
140+
141+func (_m *MockRemoteDirectory) EXPECT() *MockRemoteDirectory_Expecter {
142+ return &MockRemoteDirectory_Expecter{mock: &_m.Mock}
143+}
144+
145+// RegistryChanged provides a mock function for the type MockRemoteDirectory
146+func (_mock *MockRemoteDirectory) RegistryChanged() {
147+ _mock.Called()
148+ return
149+}
150+
151+// MockRemoteDirectory_RegistryChanged_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RegistryChanged'
152+type MockRemoteDirectory_RegistryChanged_Call struct {
153+ *mock.Call
154+}
155+
156+// RegistryChanged is a helper method to define mock.On call
157+func (_e *MockRemoteDirectory_Expecter) RegistryChanged() *MockRemoteDirectory_RegistryChanged_Call {
158+ return &MockRemoteDirectory_RegistryChanged_Call{Call: _e.mock.On("RegistryChanged")}
159+}
160+
161+func (_c *MockRemoteDirectory_RegistryChanged_Call) Run(run func()) *MockRemoteDirectory_RegistryChanged_Call {
162+ _c.Call.Run(func(args mock.Arguments) {
163+ run()
164+ })
165+ return _c
166+}
167+
168+func (_c *MockRemoteDirectory_RegistryChanged_Call) Return() *MockRemoteDirectory_RegistryChanged_Call {
169+ _c.Call.Return()
170+ return _c
171+}
172+
173+func (_c *MockRemoteDirectory_RegistryChanged_Call) RunAndReturn(run func()) *MockRemoteDirectory_RegistryChanged_Call {
174+ _c.Run(run)
175+ return _c
176+}
177+
178+// RequestReconcile provides a mock function for the type MockRemoteDirectory
179+func (_mock *MockRemoteDirectory) RequestReconcile(endpoint string) {
180+ _mock.Called(endpoint)
181+ return
182+}
183+
184+// MockRemoteDirectory_RequestReconcile_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'RequestReconcile'
185+type MockRemoteDirectory_RequestReconcile_Call struct {
186+ *mock.Call
187+}
188+
189+// RequestReconcile is a helper method to define mock.On call
190+// - endpoint string
191+func (_e *MockRemoteDirectory_Expecter) RequestReconcile(endpoint any) *MockRemoteDirectory_RequestReconcile_Call {
192+ return &MockRemoteDirectory_RequestReconcile_Call{Call: _e.mock.On("RequestReconcile", endpoint)}
193+}
194+
195+func (_c *MockRemoteDirectory_RequestReconcile_Call) Run(run func(endpoint string)) *MockRemoteDirectory_RequestReconcile_Call {
196+ _c.Call.Run(func(args mock.Arguments) {
197+ var arg0 string
198+ if args[0] != nil {
199+ arg0 = args[0].(string)
200+ }
201+ run(
202+ arg0,
203+ )
204+ })
205+ return _c
206+}
207+
208+func (_c *MockRemoteDirectory_RequestReconcile_Call) Return() *MockRemoteDirectory_RequestReconcile_Call {
209+ _c.Call.Return()
210+ return _c
211+}
212+
213+func (_c *MockRemoteDirectory_RequestReconcile_Call) RunAndReturn(run func(endpoint string)) *MockRemoteDirectory_RequestReconcile_Call {
214+ _c.Run(run)
215+ return _c
216+}
217+
218+// Snapshot provides a mock function for the type MockRemoteDirectory
219+func (_mock *MockRemoteDirectory) Snapshot() ports.RemoteDirectorySnapshot {
220+ ret := _mock.Called()
221+
222+ if len(ret) == 0 {
223+ panic("no return value specified for Snapshot")
224+ }
225+
226+ var r0 ports.RemoteDirectorySnapshot
227+ if returnFunc, ok := ret.Get(0).(func() ports.RemoteDirectorySnapshot); ok {
228+ r0 = returnFunc()
229+ } else {
230+ r0 = ret.Get(0).(ports.RemoteDirectorySnapshot)
231+ }
232+ return r0
233+}
234+
235+// MockRemoteDirectory_Snapshot_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Snapshot'
236+type MockRemoteDirectory_Snapshot_Call struct {
237+ *mock.Call
238+}
239+
240+// Snapshot is a helper method to define mock.On call
241+func (_e *MockRemoteDirectory_Expecter) Snapshot() *MockRemoteDirectory_Snapshot_Call {
242+ return &MockRemoteDirectory_Snapshot_Call{Call: _e.mock.On("Snapshot")}
243+}
244+
245+func (_c *MockRemoteDirectory_Snapshot_Call) Run(run func()) *MockRemoteDirectory_Snapshot_Call {
246+ _c.Call.Run(func(args mock.Arguments) {
247+ run()
248+ })
249+ return _c
250+}
251+
252+func (_c *MockRemoteDirectory_Snapshot_Call) Return(remoteDirectorySnapshot ports.RemoteDirectorySnapshot) *MockRemoteDirectory_Snapshot_Call {
253+ _c.Call.Return(remoteDirectorySnapshot)
254+ return _c
255+}
256+
257+func (_c *MockRemoteDirectory_Snapshot_Call) RunAndReturn(run func() ports.RemoteDirectorySnapshot) *MockRemoteDirectory_Snapshot_Call {
258+ _c.Call.Return(run)
259+ return _c
260+}
261+
262+// Subscribe provides a mock function for the type MockRemoteDirectory
263+func (_mock *MockRemoteDirectory) Subscribe() ports.RemoteDirectorySubscription {
264+ ret := _mock.Called()
265+
266+ if len(ret) == 0 {
267+ panic("no return value specified for Subscribe")
268+ }
269+
270+ var r0 ports.RemoteDirectorySubscription
271+ if returnFunc, ok := ret.Get(0).(func() ports.RemoteDirectorySubscription); ok {
272+ r0 = returnFunc()
273+ } else {
274+ if ret.Get(0) != nil {
275+ r0 = ret.Get(0).(ports.RemoteDirectorySubscription)
276+ }
277+ }
278+ return r0
279+}
280+
281+// MockRemoteDirectory_Subscribe_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Subscribe'
282+type MockRemoteDirectory_Subscribe_Call struct {
283+ *mock.Call
284+}
285+
286+// Subscribe is a helper method to define mock.On call
287+func (_e *MockRemoteDirectory_Expecter) Subscribe() *MockRemoteDirectory_Subscribe_Call {
288+ return &MockRemoteDirectory_Subscribe_Call{Call: _e.mock.On("Subscribe")}
289+}
290+
291+func (_c *MockRemoteDirectory_Subscribe_Call) Run(run func()) *MockRemoteDirectory_Subscribe_Call {
292+ _c.Call.Run(func(args mock.Arguments) {
293+ run()
294+ })
295+ return _c
296+}
297+
298+func (_c *MockRemoteDirectory_Subscribe_Call) Return(remoteDirectorySubscription ports.RemoteDirectorySubscription) *MockRemoteDirectory_Subscribe_Call {
299+ _c.Call.Return(remoteDirectorySubscription)
300+ return _c
301+}
302+
303+func (_c *MockRemoteDirectory_Subscribe_Call) RunAndReturn(run func() ports.RemoteDirectorySubscription) *MockRemoteDirectory_Subscribe_Call {
304+ _c.Call.Return(run)
305+ return _c
306+}
M internal/ports/mocks/ui_mock.go +5 -0
@@ -365,6 +365,11 @@ func (_c *MockUIObservationSink_ObserveTerminalResize_Call) RunAndReturn(run fun
365365 }
366366
367367 // ObserveTerminalWrite provides a mock function for the type MockUIObservationSink
368+//
369+// NOTE(hand-edit): the defensive copy below is intentional. Callers pass
370+// sub-slices of reused buffers, so recording the slice header without
371+// copying makes assertions flaky. `make mocks` regenerates this file via
372+// mockery and drops the copy; re-apply it after regeneration.
368373 func (_mock *MockUIObservationSink) ObserveTerminalWrite(data []byte) {
369374 _mock.Called(append([]byte(nil), data...))
370375 return
M internal/ports/remote.go +5 -2
@@ -8,9 +8,12 @@ import (
88 "github.com/bnema/vev/internal/protocol/catalogue"
99 )
1010
11-// RemoteHostStore persists pinned and learned remote host targets.
11+// RemoteHostStore persists pinned and learned remote host targets as
12+// registration records. Each record carries a persistent random incarnation
13+// so remove/re-add cycles are observable even when the endpoint string is
14+// unchanged.
1215 type RemoteHostStore interface {
13- Hosts() (pinned, learned []string, err error)
16+ Hosts() (pinned, learned []domain.RemoteRegistration, err error)
1417 AddPinned(target string) error
1518 RemovePinned(target string) error
1619 Remember(target string) error
A internal/ports/remote_runtime.go +75 -0
@@ -0,0 +1,75 @@
1+package ports
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/protocol/catalogue"
9+)
10+
11+// RemoteJobKind identifies one bounded unit of remote infrastructure work.
12+// Jobs and results are typed tagged values with request identity, not
13+// callbacks or raw frames.
14+type RemoteJobKind uint8
15+
16+const (
17+ RemoteJobRegistryRead RemoteJobKind = iota + 1
18+ RemoteJobCacheLoad
19+ RemoteJobCatalogObserve
20+ RemoteJobCacheStore
21+)
22+
23+// String returns the stable lowercase token for a job kind.
24+func (k RemoteJobKind) String() string {
25+ switch k {
26+ case RemoteJobRegistryRead:
27+ return "registry_read"
28+ case RemoteJobCacheLoad:
29+ return "cache_load"
30+ case RemoteJobCatalogObserve:
31+ return "catalog_observe"
32+ case RemoteJobCacheStore:
33+ return "cache_store"
34+ default:
35+ return "unknown"
36+ }
37+}
38+
39+// RemoteJob is one admitted unit of remote work. Registration and Generation
40+// fence the job: results bound to a superseded registration never apply.
41+// Deadline bounds catalogue observation; Entries carries cache-store payload.
42+type RemoteJob struct {
43+ Kind RemoteJobKind
44+ Endpoint string
45+ Registration domain.RemoteRegistration
46+ Generation domain.RemoteGeneration
47+ Deadline time.Duration
48+ Entries []catalogue.RemoteCatalogCacheEntry
49+}
50+
51+// RemoteJobResult is exactly one completion for an admitted job: success,
52+// failure or cancellation. Exactly one result retires one admission credit.
53+// FailureKind carries the runtime's sanitized classification; Err preserves
54+// the cause for errors.Is/As callers.
55+type RemoteJobResult struct {
56+ Kind RemoteJobKind
57+ Endpoint string
58+ Registration domain.RemoteRegistration
59+ Generation domain.RemoteGeneration
60+ FailureKind domain.RemoteFailureKind
61+ Catalog catalogue.RemoteCatalog
62+ Registrations []domain.RemoteRegistration
63+ Entries []catalogue.RemoteCatalogCacheEntry
64+ Err error
65+}
66+
67+// RemoteRuntime is the bounded infrastructure seam executing remote jobs. It
68+// owns subprocesses, storage calls and per-lane dispatch; workers never call
69+// into the use case. A successful job-channel send transfers exactly one
70+// admission credit until exactly one completion. Dispatch never blocks
71+// forwarding into an occupied lane; cancelled completions select on context
72+// cancellation.
73+type RemoteRuntime interface {
74+ Run(ctx context.Context, jobs <-chan RemoteJob, results chan<- RemoteJobResult) error
75+}
A internal/ports/remote_state.go +86 -0
@@ -0,0 +1,86 @@
1+package ports
2+
3+import (
4+ "time"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/protocol/catalogue"
8+)
9+
10+// RemoteHostSnapshot is one ordered host projection from the remote directory.
11+// Sessions are cloned ordered catalogue values; the slice is never mutated
12+// after publication. Checking is independent of Availability: an in-flight
13+// observation preserves the last-known availability and inventory.
14+type RemoteHostSnapshot struct {
15+ Endpoint string
16+ DisplayOrigin string
17+ Rank int
18+ Registration domain.RemoteRegistration
19+ Availability domain.RemoteAvailability
20+ Checking bool
21+ LastAttempt time.Time
22+ LastSuccess time.Time
23+ NextDue time.Time
24+ ConsecutiveFailures uint
25+ LastFailure domain.RemoteFailure
26+ InventoryKnown bool
27+ Sessions []catalogue.RemoteCatalogSession
28+}
29+
30+// Clone returns a defensive copy with an independent session slice.
31+func (s RemoteHostSnapshot) Clone() RemoteHostSnapshot {
32+ out := s
33+ out.Sessions = append([]catalogue.RemoteCatalogSession(nil), s.Sessions...)
34+ for i := range out.Sessions {
35+ out.Sessions[i].Tabs = append([]catalogue.RemoteCatalogTab(nil), out.Sessions[i].Tabs...)
36+ }
37+ return out
38+}
39+
40+// RemoteDirectorySnapshot is an immutable, fully defensive publication of the
41+// remote directory. Nested slices are never mutated after publication.
42+type RemoteDirectorySnapshot struct {
43+ Revision uint64
44+ Initialized bool
45+ RegistryError error
46+ Hosts []RemoteHostSnapshot
47+}
48+
49+// Clone returns a defensive copy with independent host and session slices.
50+// RegistryError is shared: published errors are immutable values.
51+func (s RemoteDirectorySnapshot) Clone() RemoteDirectorySnapshot {
52+ out := s
53+ out.Hosts = make([]RemoteHostSnapshot, len(s.Hosts))
54+ for i, host := range s.Hosts {
55+ out.Hosts[i] = host.Clone()
56+ }
57+ return out
58+}
59+
60+// Find returns the snapshot for an exact endpoint identity.
61+func (s RemoteDirectorySnapshot) Find(endpoint string) (RemoteHostSnapshot, bool) {
62+ for _, host := range s.Hosts {
63+ if host.Endpoint == endpoint {
64+ return host, true
65+ }
66+ }
67+ return RemoteHostSnapshot{}, false
68+}
69+
70+// RemoteDirectorySubscription wakes one subscriber when a newer snapshot is
71+// available. Each subscriber owns a capacity-one channel and re-reads the
72+// newest snapshot on wake; channels are never shared between subscribers.
73+type RemoteDirectorySubscription interface {
74+ Changed() <-chan struct{}
75+ Close()
76+}
77+
78+// RemoteDirectory is the read-only projection seam between the remote monitor
79+// and presentation. Snapshot and subscription access perform no I/O; request
80+// methods are non-blocking coalesced hints, never synchronous refreshes.
81+type RemoteDirectory interface {
82+ Snapshot() RemoteDirectorySnapshot
83+ Subscribe() RemoteDirectorySubscription
84+ RequestReconcile(endpoint string)
85+ RegistryChanged()
86+}
A internal/ports/remote_state_test.go +85 -0
@@ -0,0 +1,85 @@
1+package ports
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/vev/internal/domain"
7+ "github.com/bnema/vev/internal/protocol/catalogue"
8+)
9+
10+func TestRemoteJobKindString(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ value RemoteJobKind
14+ want string
15+ }{
16+ {name: "registry read", value: RemoteJobRegistryRead, want: "registry_read"},
17+ {name: "cache load", value: RemoteJobCacheLoad, want: "cache_load"},
18+ {name: "catalog observe", value: RemoteJobCatalogObserve, want: "catalog_observe"},
19+ {name: "cache store", value: RemoteJobCacheStore, want: "cache_store"},
20+ {name: "zero is unknown", value: RemoteJobKind(0), want: "unknown"},
21+ {name: "out of range is unknown", value: RemoteJobKind(99), want: "unknown"},
22+ }
23+ for _, test := range tests {
24+ t.Run(test.name, func(t *testing.T) {
25+ if got := test.value.String(); got != test.want {
26+ t.Fatalf("String() = %q, want %q", got, test.want)
27+ }
28+ })
29+ }
30+}
31+
32+func TestRemoteDirectorySnapshotCloneIsDefensive(t *testing.T) {
33+ original := RemoteDirectorySnapshot{
34+ Revision: 7, Initialized: true,
35+ Hosts: []RemoteHostSnapshot{{
36+ Endpoint: "user@arch", DisplayOrigin: "arch", Rank: 0,
37+ Registration: domain.RemoteRegistration{Endpoint: "user@arch", Incarnation: [16]byte{3}, Generation: 1},
38+ Availability: domain.RemoteAvailabilityReachable,
39+ Checking: true,
40+ InventoryKnown: true,
41+ Sessions: []catalogue.RemoteCatalogSession{{
42+ Name: "work", State: catalogue.RemoteCatalogSessionUp,
43+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "shell"}},
44+ }},
45+ }},
46+ }
47+ cloned := original.Clone()
48+ if found, ok := cloned.Find("user@arch"); !ok || found.Availability != domain.RemoteAvailabilityReachable {
49+ t.Fatalf("Find() = %+v, %t, want reachable host", found, ok)
50+ }
51+ if _, ok := cloned.Find("user@mule"); ok {
52+ t.Fatal("Find() must miss unknown endpoints")
53+ }
54+
55+ cloned.Hosts[0].Endpoint = "mutated"
56+ cloned.Hosts[0].Sessions[0].Name = "mutated"
57+ cloned.Hosts[0].Sessions[0].Tabs[0].ID = "mutated"
58+ if original.Hosts[0].Endpoint != "user@arch" || original.Hosts[0].Sessions[0].Name != "work" ||
59+ original.Hosts[0].Sessions[0].Tabs[0].ID != "tab-1" {
60+ t.Fatalf("Clone() shares memory with the original: %+v", original.Hosts[0])
61+ }
62+
63+ empty := RemoteDirectorySnapshot{}.Clone()
64+ if empty.Hosts == nil {
65+ t.Fatal("Clone() of an empty snapshot must keep a non-nil host slice")
66+ }
67+ if _, ok := empty.Find("user@arch"); ok {
68+ t.Fatal("Find() on an empty snapshot must miss")
69+ }
70+}
71+
72+func TestRemoteHostSnapshotCloneIsDefensive(t *testing.T) {
73+ original := RemoteHostSnapshot{
74+ Endpoint: "user@arch",
75+ Sessions: []catalogue.RemoteCatalogSession{{Name: "work"}},
76+ }
77+ cloned := original.Clone()
78+ cloned.Sessions[0].Name = "mutated"
79+ if original.Sessions[0].Name != "work" {
80+ t.Fatal("Clone() shares session memory with the original")
81+ }
82+ if (RemoteHostSnapshot{}).Clone().Sessions != nil {
83+ t.Fatal("Clone() of a session-less host must stay nil")
84+ }
85+}
M internal/protocol/catalogue/catalogue.go +7 -4
@@ -29,10 +29,13 @@ const (
2929 )
3030
3131 // RemoteCatalogCacheEntry is one immutable exact-schema host snapshot.
32+// Incarnation binds the entry to one host-registry registration: a zero
33+// incarnation is unbound advisory data and must never seed a registration.
3234 type RemoteCatalogCacheEntry struct {
33- Host string
34- FetchedAt time.Time
35- Sessions []RemoteCatalogSession
35+ Host string
36+ FetchedAt time.Time
37+ Incarnation [16]byte
38+ Sessions []RemoteCatalogSession
3639 }
3740
3841 // RemoteCatalogTab is one bounded, untrusted tab presentation hint.
@@ -264,7 +267,7 @@ func validText(value string) bool {
264267
265268 func validReason(reason string) bool {
266269 switch reason {
267- case "", "refreshing", "catalog_stale", "host_unreachable", "version_mismatch", "session_down", "session_broken", "identity_changed", "not_found", "timeout", "malformed":
270+ case "", domain.RemoteReasonRefreshing, domain.RemoteReasonCatalogStale, domain.RemoteReasonHostUnreachable, domain.RemoteReasonVersionMismatch, domain.RemoteReasonSessionDown, domain.RemoteReasonSessionBroken, domain.RemoteReasonIdentityChanged, domain.RemoteReasonNotFound, domain.RemoteReasonTimeout, domain.RemoteReasonMalformed:
268271 return true
269272 default:
270273 return false
M internal/usecase/daemon/control.go +1 -1
@@ -748,7 +748,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) {
748748 reason := ""
749749 if entry.broken() {
750750 state = catalogue.RemoteCatalogSessionBroken
751- reason = "session_broken"
751+ reason = domain.RemoteReasonSessionBroken
752752 }
753753 rows = append(rows, catalogue.RemoteCatalogSession{
754754 LifecycleID: entry.incarnation,
M internal/usecase/daemon/control_test.go +24 -0
@@ -737,6 +737,30 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) {
737737 require.Equal(t, true, decoded[0]["focused"])
738738 }
739739
740+func TestRemoteCatalogLeavesStoppedSessionsStopped(t *testing.T) {
741+ d := newTestDaemon(t, nil, stubClock{})
742+ d.mu.Lock()
743+ d.inactive["old"] = inactiveSession{name: "old", cwd: "/tmp/old", createdAt: 1, incarnation: domain.IncarnationID{3}, state: protocol.SessionDown}
744+ d.mu.Unlock()
745+
746+ result := sendCommand(t, d, protocol.CommandRequest{Slug: "remote-catalog", JSON: true})
747+ require.True(t, result.OK, result.Text)
748+
749+ // Observation is read-only: the stopped record stays stopped, is not
750+ // resumed into a live session, and is not purged.
751+ d.mu.Lock()
752+ defer d.mu.Unlock()
753+ entry, ok := d.inactive["old"]
754+ require.True(t, ok, "stopped record must survive observation")
755+ require.Equal(t, protocol.SessionDown, entry.state)
756+ for _, sess := range d.sessions {
757+ sess.mu.Lock()
758+ name := sess.name
759+ sess.mu.Unlock()
760+ require.NotEqual(t, "old", name, "observation must not resume stopped sessions")
761+ }
762+}
763+
740764 func TestRemoteCatalogJSONOutput(t *testing.T) {
741765 d := newTestDaemon(t, nil, stubClock{})
742766 work := addControlSession(d, "work", "t_work", "p_work")
M internal/usecase/daemon/daemon.go +75 -17
@@ -298,14 +298,15 @@ type Daemon struct {
298298 barScripts *barScriptState
299299 notices *noticeCenter
300300 resumeParkGrace time.Duration
301- // remoteCatalog owns cache-derived discovery state independently of the
302- // live attachment registry. Its cache reads and writes never hold d.mu.
303- remoteCatalog remoteCatalogState
304- remotePreview remotePreviewState
305- remoteHostStore ports.RemoteHostStore
306- remoteCatalogClient ports.RemoteCatalogClient
307- remoteCatalogCache ports.RemoteCatalogCache
308- remotePreviewClient ports.RemotePreviewClient
301+ remotePreview remotePreviewState
302+ remotePreviewClient ports.RemotePreviewClient
303+ // remoteDirectory is the snapshot-only projection served by the remote
304+ // monitor. Presentation reads it without I/O; nil disables remote
305+ // directory projections without affecting local behavior.
306+ remoteDirectory ports.RemoteDirectory
307+ // remoteMonitorRun starts the monitor policy loop. Serve owns its
308+ // lifetime; nil disables background monitoring.
309+ remoteMonitorRun func(context.Context) error
309310 // tempDir overrides os.TempDir() for clipboard-image-transfer writes
310311 // (see clipboard.go); empty means use os.TempDir().
311312 tempDir string
@@ -415,12 +416,13 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option {
415416 return func(d *Daemon) { d.runtimeObserver = observer }
416417 }
417418
418-// WithRemoteDiscovery installs the remote discovery ports used by the daemon.
419-func WithRemoteDiscovery(store ports.RemoteHostStore, catalog ports.RemoteCatalogClient, cache ports.RemoteCatalogCache) Option {
419+// WithRemoteMonitor installs the snapshot-only remote directory and the
420+// monitor runner. Serve starts the runner without waiting for registry,
421+// cache or runtime readiness and stops it with its own context.
422+func WithRemoteMonitor(directory ports.RemoteDirectory, run func(context.Context) error) Option {
420423 return func(d *Daemon) {
421- d.remoteHostStore = store
422- d.remoteCatalogClient = catalog
423- d.remoteCatalogCache = cache
424+ d.remoteDirectory = directory
425+ d.remoteMonitorRun = run
424426 }
425427 }
426428
@@ -606,7 +608,18 @@ func (d *Daemon) allocateLifecycleCreatedAtLocked() (int64, error) {
606608 }
607609
608610 func (d *Daemon) nowUnixNano() int64 {
609- return d.clock.Now().UnixNano()
611+ return d.daemonNow().UnixNano()
612+}
613+
614+// daemonNow reports the daemon clock. New defaults a nil clock to the
615+// system clock, so the fallback below only serves hand-built fixtures
616+// and stays explicit about its provenance instead of hiding a bare
617+// wall-clock read.
618+func (d *Daemon) daemonNow() time.Time {
619+ if d == nil || d.clock == nil {
620+ return systemClock{}.Now()
621+ }
622+ return d.clock.Now()
610623 }
611624
612625 type systemClock struct{}
@@ -670,7 +683,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
670683 snapshotAdmitted: make(map[*snapshotCapture]struct{}),
671684 snapshotWake: make(chan struct{}, 1),
672685 notices: newNoticeCenter(),
673- remoteCatalog: newRemoteCatalogState(),
674686 resumeParkGrace: defaultResumeParkGrace,
675687 barScripts: &barScriptState{
676688 cfg: barConfigFromDomain(domain.Defaults().Bar),
@@ -730,10 +742,14 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt
730742 d.lastAllocatedCreatedAt = maxCreatedAt
731743 }
732744 d.mruSeq.Store(maxSeq)
733- d.loadRemoteCatalogCache()
734745 return d
735746 }
736747
748+// remoteMonitorShutdownJoin bounds the daemon's wait for monitor teardown.
749+// The monitor bounds its own runtime cleanup to two seconds; this join only
750+// covers scheduling slack and never enters the session worker group.
751+const remoteMonitorShutdownJoin = 3 * time.Second
752+
737753 // Serve runs the accept loop over l, owning it for the loop's lifetime. It
738754 // returns when the last session is removed or ctx is cancelled; on the latter
739755 // path attached clients are detached with ReasonServerShutdown.
@@ -743,6 +759,49 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error {
743759 d.hardCtx, d.hardCancel = context.WithCancel(context.Background())
744760 defer d.hardCancel()
745761
762+ // Remote directory projections relay without I/O: the watcher rebuilds
763+ // only open views on publication and never requests reconciliation.
764+ // The watcher and the monitor runner share one bounded shutdown budget
765+ // below, outside the session worker group.
766+ watcherDone := make(chan struct{})
767+ if d.remoteDirectory != nil {
768+ go func() {
769+ defer close(watcherDone)
770+ d.watchRemoteDirectory(d.serveCtx)
771+ }()
772+ } else {
773+ close(watcherDone)
774+ }
775+ var monitorDone chan error
776+ if d.remoteMonitorRun != nil {
777+ monitorDone = make(chan error, 1)
778+ go func() { monitorDone <- d.remoteMonitorRun(d.serveCtx) }()
779+ }
780+ // Bounded join outside the session worker group: remote teardown never
781+ // extends daemon shutdown past this single budget. The monitor runner
782+ // owns its runtime's two-second cleanup join, so joining the runner
783+ // transitively joins the workers; the watcher owns no I/O or workers.
784+ // This join is a wall-time operational bound on purpose: it uses the
785+ // package wall clock (like the handshake timeout) instead of the
786+ // injected daemon clock, whose timers may never fire in tests.
787+ defer func() {
788+ join := systemClock{}.NewTimer(remoteMonitorShutdownJoin)
789+ defer join.Stop()
790+ if monitorDone != nil {
791+ select {
792+ case <-monitorDone:
793+ case <-join.C():
794+ d.log.Warn("remote monitor shutdown join timed out")
795+ return
796+ }
797+ }
798+ select {
799+ case <-watcherDone:
800+ case <-join.C():
801+ d.log.Warn("remote directory watcher shutdown join timed out")
802+ }
803+ }()
804+
746805 d.sessWg.Go(func() {
747806 d.attentionAnimator(d.serveCtx)
748807 })
@@ -855,7 +914,6 @@ func (d *Daemon) purgeAll(reason uint8) (checkpointIncomplete bool) {
855914 }
856915
857916 func (d *Daemon) terminateAllWithSnapshotDeadline(reason uint8, purge bool, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) {
858- d.cancelRemoteDiscoveryRefresh()
859917 d.closeMoveLifecycles()
860918 d.mu.Lock()
861919 d.closing = true
M internal/usecase/daemon/move_picker.go +14 -2
@@ -195,15 +195,27 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions
195195 }
196196 rt.pickerMu.Lock()
197197 updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerRefreshSequence == refreshSequence && rt.pickerIntent == intent && rt.pickerSource == source
198+ var before, after picker.Target
199+ var haveBefore, haveAfter bool
198200 if updated {
201+ before, haveBefore = rt.picker.Selected()
199202 rt.picker.ReplaceFrom(model)
200203 if intent == pickerNavigate && opts.nearestRow >= 0 {
201204 rt.picker.SelectNearestRow(opts.nearestRow)
202205 }
203206 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
207+ after, haveAfter = rt.picker.Selected()
204208 }
205209 rt.pickerMu.Unlock()
206- if updated {
207- d.registerPreviewForSelection(ac)
210+ if !updated {
211+ return
212+ }
213+ // A directory publication rebuilds rows around the same selection:
214+ // keep an in-flight remote preview when the exact selected route did
215+ // not change instead of cancelling it on every revision. Availability
216+ // reasons render around the route and are ignored here.
217+ if haveBefore && haveAfter && pickerRouteTargetsEqual(before, after) {
218+ return
208219 }
220+ d.registerPreviewForSelection(ac)
209221 }
M internal/usecase/daemon/overlay_runtime.go +2 -4
@@ -39,10 +39,8 @@ type overlayRuntime struct {
3939 pickerESC pendingByteTimer
4040
4141 // Test-only, unsynchronized lifecycle seams. Assign them before picker
42- // publication or goroutine startup. Hooks run without pickerMu or
43- // remoteCatalog.mu held.
44- beforeRemotePickerRegistration func()
45- afterPickerRefreshBuild func(*picker.Model)
42+ // publication or goroutine startup.
43+ afterPickerRefreshBuild func(*picker.Model)
4644
4745 paletteMu sync.Mutex
4846 palette *palette.Model
M internal/usecase/daemon/palette.go +52 -46
@@ -7,6 +7,7 @@ import (
77
88 renderer "github.com/bnema/vev-vt"
99 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
1011 "github.com/bnema/vev/internal/protocol"
1112 "github.com/bnema/vev/internal/protocol/catalogue"
1213 "github.com/bnema/vev/internal/usecase/command"
@@ -56,12 +57,8 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) {
5657 ac.overlays.palettePreview = ""
5758 ac.overlays.paletteFeedback = ""
5859 ac.overlays.palettePending = nil
59- generation := ac.overlays.paletteGeneration
6060 ac.overlays.paletteMu.Unlock()
6161 d.invalidateRender(sess, ac, true, "palette.go")
62- d.remoteDiscoveryOpened(remoteDiscoveryInstance{
63- ac: ac, kind: remoteDiscoveryPalette, generation: generation, palette: model,
64- })
6562 }
6663
6764 type paletteSessionIdentity struct {
@@ -118,9 +115,10 @@ func routeEntryForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteR
118115 return protocol.RecentRouteEntry{}, false
119116 }
120117
121-func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, currentLifecycle domain.SessionLifecycleID, remoteCatalog []remoteCatalogPresentationEntry, hostRanks map[string]int) []palette.Result {
122- results := make([]palette.Result, 0, len(remoteCatalog)+2)
118+func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, currentLifecycle domain.SessionLifecycleID, hosts []ports.RemoteHostSnapshot) []palette.Result {
119+ results := make([]palette.Result, 0, len(hosts)+2)
123120 servingOrigin := ""
121+ servingEndpoint := ""
124122 if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal {
125123 results = append(results, palette.NewCreateSessionDestination(
126124 palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation,
@@ -128,28 +126,42 @@ func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, curr
128126 }
129127 if active, ok := activeRouteEntryForLifecycle(snapshot, currentLifecycle); ok && active.Kind == protocol.RouteKindRemote {
130128 servingOrigin = paletteRemoteDisplayOrigin(active.HostLabel)
129+ servingEndpoint = servingRouteEndpoint(hosts, active.Target.LifecycleID)
131130 results = append(results, palette.NewCreateSessionDestination(
132131 palette.CreateSessionOnServingDaemon, servingOrigin, "", protocol.RouteRef{}, 0,
133132 ))
134133 }
135- for _, host := range remoteCatalog {
136- if host.status != remoteHostFresh || host.entry.Host == "" {
137- continue
138- }
139- if _, configured := hostRanks[host.entry.Host]; !configured {
134+ for _, host := range hosts {
135+ if host.Endpoint == "" {
140136 continue
141137 }
142- displayOrigin := paletteRemoteDisplayOrigin(host.entry.Host)
143- if servingOrigin != "" && displayOrigin == servingOrigin {
138+ displayOrigin := paletteRemoteDisplayOrigin(host.Endpoint)
139+ if servingEndpoint != "" && host.Endpoint == servingEndpoint {
144140 continue
145141 }
146142 results = append(results, palette.NewCreateSessionDestination(
147- palette.CreateSessionOnRemoteHost, displayOrigin, host.entry.Host, protocol.RouteRef{}, 0,
143+ palette.CreateSessionOnRemoteHost, displayOrigin, host.Endpoint, protocol.RouteRef{}, 0,
148144 ))
149145 }
150146 return results
151147 }
152148
149+// servingRouteEndpoint resolves the exact endpoint serving the active
150+// remote route by matching its session lifecycle against the directory
151+// inventory. Display origins collide across login prefixes (vev@arch vs
152+// arch), so only an exact lifecycle match suppresses the duplicate
153+// per-host destination.
154+func servingRouteEndpoint(hosts []ports.RemoteHostSnapshot, lifecycle domain.SessionLifecycleID) string {
155+ for _, host := range hosts {
156+ for _, session := range host.Sessions {
157+ if session.LifecycleID == lifecycle {
158+ return host.Endpoint
159+ }
160+ }
161+ }
162+ return ""
163+}
164+
153165 func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool {
154166 if entry.Kind == protocol.RouteKindLocal {
155167 return true
@@ -158,11 +170,16 @@ func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemon
158170 paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin
159171 }
160172
161-func remotePaletteUnavailableReason(status remoteHostStatus, session catalogue.RemoteCatalogSession) string {
173+func remotePaletteUnavailableReason(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession) string {
162174 if session.State == catalogue.RemoteCatalogSessionBroken {
163- return "session_broken"
175+ return domain.RemoteReasonSessionBroken
164176 }
165- return remoteReasonForStatus(status)
177+ return directorySessionReason(host, session, mustRemotePaletteTarget(host.Endpoint, session))
178+}
179+
180+func mustRemotePaletteTarget(endpoint string, session catalogue.RemoteCatalogSession) domain.RemoteSessionTarget {
181+ _, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{Host: endpoint, Name: session.Name}, session)
182+ return target
166183 }
167184
168185 // paletteResults captures eligible named sessions before paletteMu so the
@@ -187,15 +204,15 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
187204 }
188205 daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle)
189206
190- remoteCatalog := d.remoteCatalogSnapshot()
191- hostRanks := d.remoteHostRanks()
192- sortRemoteCatalog(remoteCatalog, hostRanks)
207+ directory := d.remoteDirectorySnapshot()
208+ hosts := append([]ports.RemoteHostSnapshot(nil), directory.Hosts...)
209+ sortDirectoryHosts(hosts)
193210 remoteSessionCount := 0
194- for _, host := range remoteCatalog {
195- remoteSessionCount += len(host.entry.Sessions)
211+ for _, host := range hosts {
212+ remoteSessionCount += len(host.Sessions)
196213 }
197214
198- destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle, remoteCatalog, hostRanks)
215+ destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle, hosts)
199216 results := make([]palette.Result, 0, len(commands)+len(destinations)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries))
200217 for _, cmd := range commands {
201218 results = append(results, palette.NewCommandResult(cmd))
@@ -232,13 +249,13 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
232249 }
233250 discovered := make([]discoveredRemote, 0, remoteSessionCount)
234251 discoveredByPresentation := make(map[remotePalettePresentationIdentity][]paletteSessionIdentity, remoteSessionCount)
235- for _, host := range remoteCatalog {
236- for _, session := range host.entry.Sessions {
252+ for _, host := range hosts {
253+ for _, session := range host.Sessions {
237254 if session.Ephemeral {
238255 continue
239256 }
240257 key, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{
241- Host: host.entry.Host, Name: session.Name,
258+ Host: host.Endpoint, Name: session.Name,
242259 }, session)
243260 if key.Validate() != nil || target.Validate() != nil {
244261 continue
@@ -249,7 +266,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro
249266 }
250267 discovered = append(discovered, discoveredRemote{
251268 identity: identity,
252- result: palette.NewRemoteSessionResult(key, target, remotePaletteUnavailableReason(host.status, session)),
269+ result: palette.NewRemoteSessionResult(key, target, remotePaletteUnavailableReason(host, session)),
253270 })
254271 discoveredByPresentation[presentation] = append(discoveredByPresentation[presentation], identity)
255272 }
@@ -408,7 +425,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
408425 var generation uint64
409426 var rawQuery string
410427 changed, cancel, execute, chooseDestination := false, false, false, false
411- var closedDiscovery remoteDiscoveryInstance
412428 var effect *attachmentEffect
413429 if len(effects) != 0 {
414430 effect = effects[0]
@@ -530,11 +546,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ...
530546 }
531547 }
532548 if cancel {
533- closedDiscovery = ac.clearPaletteLocked()
549+ ac.clearPaletteLocked()
534550 }
535551 ac.overlays.paletteMu.Unlock()
536552 if cancel {
537- d.remoteDiscoveryClosed(closedDiscovery)
538553 d.invalidateRender(entry, ac, true, "palette.go")
539554 return
540555 }
@@ -715,8 +730,7 @@ func paletteArgs(query string, cmd command.Command) []string {
715730 return action.Args
716731 }
717732
718-func (ac *attachedClient) clearPaletteLocked() remoteDiscoveryInstance {
719- model := ac.overlays.palette
733+func (ac *attachedClient) clearPaletteLocked() {
720734 ac.overlays.paletteGeneration++
721735 ac.overlays.palette = nil
722736 ac.overlays.paletteRouteSnapshot = protocol.RecentRouteSnapshot{}
@@ -724,9 +738,6 @@ func (ac *attachedClient) clearPaletteLocked() remoteDiscoveryInstance {
724738 ac.overlays.palettePreview = ""
725739 ac.overlays.paletteFeedback = ""
726740 ac.overlays.palettePending = nil
727- return remoteDiscoveryInstance{
728- ac: ac, kind: remoteDiscoveryPalette, generation: ac.overlays.paletteGeneration, palette: model,
729- }
730741 }
731742
732743 func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) {
@@ -744,9 +755,8 @@ func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, raw
744755 ac.overlays.paletteMu.Unlock()
745756 return false
746757 }
747- instance := ac.clearPaletteLocked()
758+ ac.clearPaletteLocked()
748759 ac.overlays.paletteMu.Unlock()
749- d.remoteDiscoveryClosed(instance)
750760 return true
751761 }
752762
@@ -763,9 +773,8 @@ func (d *Daemon) closePaletteIfCurrent(ac *attachedClient, generation uint64) bo
763773 ac.overlays.paletteMu.Unlock()
764774 return false
765775 }
766- instance := ac.clearPaletteLocked()
776+ ac.clearPaletteLocked()
767777 ac.overlays.paletteMu.Unlock()
768- d.remoteDiscoveryClosed(instance)
769778 return true
770779 }
771780
@@ -927,13 +936,10 @@ func (d *Daemon) remoteCreateHostReady(endpoint string) bool {
927936 if d == nil || domain.ValidateRemoteHostTarget(endpoint) != nil {
928937 return false
929938 }
930- ranks := d.remoteHostRanks()
931- if _, ok := ranks[endpoint]; !ok {
932- return false
933- }
934- d.remoteCatalog.mu.Lock()
935- defer d.remoteCatalog.mu.Unlock()
936- _, ok := d.remoteCatalogEntryLocked(endpoint)
939+ // Registration is creation authority: any registered endpoint is
940+ // attemptable at any inventory age. Freshness renders; the destination
941+ // rejects precisely on exact identity.
942+ _, ok := d.remoteDirectorySnapshot().Find(endpoint)
937943 return ok
938944 }
939945
M internal/usecase/daemon/palette_test.go +90 -87
@@ -11,6 +11,7 @@ import (
1111
1212 renderer "github.com/bnema/vev-vt"
1313 "github.com/bnema/vev/internal/domain"
14+ "github.com/bnema/vev/internal/ports"
1415 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1516 "github.com/bnema/vev/internal/protocol"
1617 "github.com/bnema/vev/internal/protocol/catalogue"
@@ -62,15 +63,15 @@ func TestCreateSessionDestinationResultsUseStructuredRouteAuthority(t *testing.T
6263 Target: testRouteTarget("home", 2), Name: "home",
6364 }},
6465 }
65- catalog := []remoteCatalogPresentationEntry{
66- {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-a"}, status: remoteHostFresh},
67- {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-b"}, status: remoteHostFresh},
68- {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-c"}, status: remoteHostStale},
69- {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-d"}, status: remoteHostFresh},
66+ hosts := []ports.RemoteHostSnapshot{
67+ {Endpoint: "host-a", Sessions: []catalogue.RemoteCatalogSession{{Name: "work", LifecycleID: lifecycle}}},
68+ {Endpoint: "host-b"},
69+ {Endpoint: "host-c"},
70+ {Endpoint: "host-d"},
7071 }
7172
72- results := createSessionDestinationResults(snapshot, lifecycle, catalog, map[string]int{"host-a": 0, "host-b": 1, "host-c": 2})
73- require.Len(t, results, 3)
73+ results := createSessionDestinationResults(snapshot, lifecycle, hosts)
74+ require.Len(t, results, 5)
7475 _, kind, _, endpoint, route, ok := results[0].CreateSessionDestination()
7576 require.True(t, ok)
7677 require.Equal(t, palette.CreateSessionOnLocalRoute, kind)
@@ -89,6 +90,13 @@ func TestCreateSessionDestinationResultsUseStructuredRouteAuthority(t *testing.T
8990 require.Equal(t, palette.CreateSessionOnRemoteHost, kind)
9091 require.Equal(t, "host-b", origin)
9192 require.Equal(t, "host-b", endpoint)
93+ // Stale inventory stays attemptable: freshness renders, registration
94+ // authorizes. The destination rejects precisely on exact identity.
95+ _, kind, origin, endpoint, _, ok = results[3].CreateSessionDestination()
96+ require.True(t, ok)
97+ require.Equal(t, palette.CreateSessionOnRemoteHost, kind)
98+ require.Equal(t, "host-c", origin)
99+ require.Equal(t, "host-c", endpoint)
92100 }
93101
94102 func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) {
@@ -99,6 +107,38 @@ func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) {
99107 require.ErrorIs(t, err, errCreateDestinationUnavailable)
100108 }
101109
110+// TestCreateSessionDestinationsKeepCollidingDisplayOrigins proves the
111+// serving-daemon suppression compares exact endpoint identity, not display
112+// labels: user@arch and ops@arch share the origin "arch", but only the
113+// endpoint serving the active route is suppressed.
114+func TestCreateSessionDestinationsKeepCollidingDisplayOrigins(t *testing.T) {
115+ lifecycle := domain.SessionLifecycleID{7}
116+ snapshot := protocol.RecentRouteSnapshot{
117+ Generation: 1,
118+ Active: protocol.RouteRef{Key: 1, Generation: 1},
119+ ActiveEntry: protocol.RecentRouteEntry{
120+ Key: 1, Generation: 1, Kind: protocol.RouteKindRemote, HostLabel: "arch",
121+ Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",
122+ },
123+ }
124+ hosts := []ports.RemoteHostSnapshot{
125+ {Endpoint: "user@arch", Sessions: []catalogue.RemoteCatalogSession{{Name: "work", LifecycleID: lifecycle}}},
126+ {Endpoint: "ops@arch"},
127+ }
128+
129+ results := createSessionDestinationResults(snapshot, lifecycle, hosts)
130+ require.Len(t, results, 2)
131+ _, kind, origin, endpoint, _, ok := results[0].CreateSessionDestination()
132+ require.True(t, ok)
133+ require.Equal(t, palette.CreateSessionOnServingDaemon, kind)
134+ require.Equal(t, "arch", origin)
135+ _, kind, origin, endpoint, _, ok = results[1].CreateSessionDestination()
136+ require.True(t, ok)
137+ require.Equal(t, palette.CreateSessionOnRemoteHost, kind)
138+ require.Equal(t, "arch", origin)
139+ require.Equal(t, "ops@arch", endpoint)
140+}
141+
102142 func TestCreateSessionDestinationResultsDoNotInferLocalFromRemoteHome(t *testing.T) {
103143 lifecycle := domain.SessionLifecycleID{1}
104144 snapshot := protocol.RecentRouteSnapshot{
@@ -110,7 +150,7 @@ func TestCreateSessionDestinationResultsDoNotInferLocalFromRemoteHome(t *testing
110150 Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work",
111151 },
112152 }
113- results := createSessionDestinationResults(snapshot, lifecycle, nil, nil)
153+ results := createSessionDestinationResults(snapshot, lifecycle, nil)
114154 require.Len(t, results, 1)
115155 _, kind, _, _, _, ok := results[0].CreateSessionDestination()
116156 require.True(t, ok)
@@ -508,16 +548,10 @@ func TestPaletteIncludesExactRemoteCatalogTargetBesideSameNameLocalSession(t *te
508548 local := addControlSession(d, "vev", "tab-2", "pane-2")
509549 local.ephemeral = false
510550 remoteLifecycle := domain.SessionLifecycleID{21}
511- d.remoteCatalog.mu.Lock()
512- d.remoteCatalog.cache["user@arch"] = catalogue.RemoteCatalogCacheEntry{
513- Host: "user@arch", FetchedAt: now,
514- Sessions: []catalogue.RemoteCatalogSession{{
515- LifecycleID: remoteLifecycle, Name: "vev", State: catalogue.RemoteCatalogSessionUp,
516- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}}, ActiveTabID: "tab-remote",
517- }},
518- }
519- d.remoteCatalog.status["user@arch"] = remoteHostFresh
520- d.remoteCatalog.mu.Unlock()
551+ seedRemoteDirectory(t, d, reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{
552+ LifecycleID: remoteLifecycle, Name: "vev", State: catalogue.RemoteCatalogSessionUp,
553+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}}, ActiveTabID: "tab-remote",
554+ }))
521555
522556 results := d.paletteResults(current, nil, protocol.RecentRouteSnapshot{})
523557 var matching []palette.Result
@@ -595,18 +629,14 @@ func TestPaletteMatchesRecentRemoteRouteToCatalog(t *testing.T) {
595629 current := addControlSession(d, "current", "tab-1", "pane-1")
596630 current.ephemeral = false
597631 lifecycle := domain.SessionLifecycleID{22}
598- d.remoteCatalog.mu.Lock()
632+ hosts := make([]ports.RemoteHostSnapshot, 0, len(test.catalogEndpoints))
599633 for _, endpoint := range test.catalogEndpoints {
600- d.remoteCatalog.cache[endpoint] = catalogue.RemoteCatalogCacheEntry{
601- Host: endpoint, FetchedAt: now,
602- Sessions: []catalogue.RemoteCatalogSession{{
603- LifecycleID: lifecycle, Name: "vev", State: catalogue.RemoteCatalogSessionUp,
604- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
605- }},
606- }
607- d.remoteCatalog.status[endpoint] = remoteHostFresh
634+ hosts = append(hosts, reachableDirectoryHost(endpoint, now, catalogue.RemoteCatalogSession{
635+ LifecycleID: lifecycle, Name: "vev", State: catalogue.RemoteCatalogSessionUp,
636+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
637+ }))
608638 }
609- d.remoteCatalog.mu.Unlock()
639+ seedRemoteDirectory(t, d, hosts...)
610640
611641 results := d.paletteResults(current, nil, protocol.RecentRouteSnapshot{
612642 Generation: 2,
@@ -646,16 +676,10 @@ func TestPaletteRemoteCatalogSelectionSendsExactAttachTarget(t *testing.T) {
646676 now := time.Unix(1_000, 0)
647677 d.clock = fixedRemoteRefreshClock{now: now}
648678 remoteLifecycle := domain.SessionLifecycleID{31}
649- d.remoteCatalog.mu.Lock()
650- d.remoteCatalog.cache["user@arch"] = catalogue.RemoteCatalogCacheEntry{
651- Host: "user@arch", FetchedAt: now,
652- Sessions: []catalogue.RemoteCatalogSession{{
653- LifecycleID: remoteLifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
654- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-work", Index: 0, Name: "shell"}}, ActiveTabID: "tab-work",
655- }},
656- }
657- d.remoteCatalog.status["user@arch"] = remoteHostFresh
658- d.remoteCatalog.mu.Unlock()
679+ seedRemoteDirectory(t, d, reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{
680+ LifecycleID: remoteLifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
681+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-work", Index: 0, Name: "shell"}}, ActiveTabID: "tab-work",
682+ }))
659683 token := beginRecentRoutePaletteEffect(t, d, current, ac)
660684
661685 d.handleInputForAttachment(token, []byte("\x1b "))
@@ -674,27 +698,20 @@ func TestPaletteRemoteCatalogSelectionSendsExactAttachTarget(t *testing.T) {
674698 RemoteTarget: &remoteTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
675699 }, target)
676700 require.False(t, ac.overlays.paletteActive())
677- d.remoteCatalog.mu.Lock()
678- require.Zero(t, d.remoteCatalog.consumers[ac]&remoteDiscoveryPalette)
679- d.remoteCatalog.mu.Unlock()
680701 }
681702
682-func TestPaletteCachedRemoteSelectionFailsClosed(t *testing.T) {
703+func TestPaletteUnknownRemoteSelectionFailsClosed(t *testing.T) {
683704 p, release := newBlockingPTY(t)
684705 defer release()
685706 d, current, ac, sends := newManualSessionWithPTYs(t, p)
686707 now := time.Unix(1_000, 0)
687708 d.clock = fixedRemoteRefreshClock{now: now}
688- d.remoteCatalog.mu.Lock()
689- d.remoteCatalog.cache["arch"] = catalogue.RemoteCatalogCacheEntry{
690- Host: "arch", FetchedAt: now,
691- Sessions: []catalogue.RemoteCatalogSession{{
692- LifecycleID: domain.SessionLifecycleID{32}, Name: "cached", State: catalogue.RemoteCatalogSessionUp,
693- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-cached", Index: 0}}, ActiveTabID: "tab-cached",
694- }},
695- }
696- d.remoteCatalog.status["arch"] = remoteHostCached
697- d.remoteCatalog.mu.Unlock()
709+ // Unknown host with no inventory offers no remote session rows: there
710+ // is no exact target to validate, so the palette stays open without
711+ // emitting a handoff or switching the source session.
712+ seedRemoteDirectory(t, d, ports.RemoteHostSnapshot{
713+ Endpoint: "arch", Availability: domain.RemoteAvailabilityUnknown, Checking: true,
714+ })
698715 token := beginRecentRoutePaletteEffect(t, d, current, ac)
699716
700717 d.handleInputForAttachment(token, []byte("\x1b "))
@@ -703,10 +720,6 @@ func TestPaletteCachedRemoteSelectionFailsClosed(t *testing.T) {
703720
704721 require.Same(t, current, ac.currentAttachmentSession())
705722 require.True(t, ac.overlays.paletteActive())
706- history := d.notices.history()
707- require.NotEmpty(t, history)
708- require.Contains(t, history[len(history)-1].Message, "refreshing")
709- require.NotContains(t, history[len(history)-1].Message, "identity changed")
710723 for {
711724 select {
712725 case frame := <-sends:
@@ -717,25 +730,27 @@ func TestPaletteCachedRemoteSelectionFailsClosed(t *testing.T) {
717730 }
718731 }
719732
720-func TestRemoteRefreshUpdatesOpenPaletteAndPreservesQuery(t *testing.T) {
721- hosts := &remoteRefreshHostStore{hosts: []string{"user@arch"}}
722- d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(1_000, 0))
733+func TestRemoteDirectoryUpdatesOpenPaletteAndPreservesQuery(t *testing.T) {
734+ d := newRemotePickerDaemon()
735+ seedRemoteDirectory(t, d)
723736 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "current")
724737
725738 d.enterPalette(sess, ac)
726- request := receiveRemotePicker(t, catalog.requests, "palette catalog request")
727739 ac.overlays.paletteMu.Lock()
728740 model := ac.overlays.palette
741+ require.NotNil(t, model)
729742 for _, r := range "vev" {
730743 model.Insert(r)
731744 }
732745 ac.overlays.paletteMu.Unlock()
733- request.result <- remoteRefreshResult{catalog: remoteCatalogForTest(catalogue.RemoteCatalogSession{
746+
747+ // A new directory publication rebuilds the open palette in place:
748+ // the model identity and the user's query survive the rebuild.
749+ seedRemoteDirectory(t, d, reachableDirectoryHost("user@arch", time.Unix(1_000, 0), catalogue.RemoteCatalogSession{
734750 LifecycleID: domain.SessionLifecycleID{41}, Name: "vev", State: catalogue.RemoteCatalogSessionUp,
735751 Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev",
736- })}
737- receiveRemotePicker(t, cache.stores, "palette catalog cache store")
738- d.sessWg.Wait()
752+ }))
753+ d.refreshPalette(ac)
739754
740755 ac.overlays.paletteMu.Lock()
741756 require.Same(t, model, ac.overlays.palette)
@@ -749,39 +764,27 @@ func TestRemoteRefreshUpdatesOpenPaletteAndPreservesQuery(t *testing.T) {
749764 require.Contains(t, displayTexts, "Switch to session vev@arch")
750765
751766 d.closePalette(ac)
752- d.remoteCatalog.mu.Lock()
753- require.NotContains(t, d.remoteCatalog.consumers, ac)
754- require.Nil(t, d.remoteCatalog.cancel)
755- d.remoteCatalog.mu.Unlock()
767+ require.False(t, ac.overlays.paletteActive(), "closing the palette releases view state only")
756768 }
757769
758-func TestRemoteRefreshTracksPickerAndPaletteOnSameAttachment(t *testing.T) {
759- hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
760- d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(1_100, 0))
770+func TestRemoteDirectoryTracksPickerAndPaletteOnSameAttachment(t *testing.T) {
771+ d := newRemotePickerDaemon()
772+ seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(1_100, 0), directorySessionForTest("work")))
761773 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
762774
775+ // Opening overlays builds from the latest snapshot without requesting
776+ // reconciliation; closing one overlay leaves the other intact.
763777 d.publishPicker(sess, ac, d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
764- request := receiveRemotePicker(t, catalog.requests, "picker catalog request")
765778 d.enterPalette(sess, ac)
766- receiveRemotePickerClose(t, request.ctx.Done(), "superseded picker catalog request")
767- request = receiveRemotePicker(t, catalog.requests, "shared catalog request")
779+ require.True(t, ac.overlays.pickerActive())
780+ require.True(t, ac.overlays.paletteActive())
768781
769782 d.closePalette(ac)
770- select {
771- case <-request.ctx.Done():
772- t.Fatal("refresh canceled while the picker remained open")
773- default:
774- }
775- d.remoteCatalog.mu.Lock()
776- require.Equal(t, remoteDiscoveryPicker, d.remoteCatalog.consumers[ac])
777- d.remoteCatalog.mu.Unlock()
783+ require.False(t, ac.overlays.paletteActive())
784+ require.True(t, ac.overlays.pickerActive(), "closing the palette must not disturb the open picker")
778785
779786 d.closePicker(ac)
780- receiveRemotePickerClose(t, request.ctx.Done(), "last discovery consumer cancellation")
781- d.remoteCatalog.mu.Lock()
782- require.Empty(t, d.remoteCatalog.consumers)
783- require.Nil(t, d.remoteCatalog.cancel)
784- d.remoteCatalog.mu.Unlock()
787+ require.False(t, ac.overlays.pickerActive())
785788 }
786789
787790 func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) {
M internal/usecase/daemon/picker.go +53 -56
@@ -9,6 +9,7 @@ import (
99
1010 renderer "github.com/bnema/vev-vt"
1111 "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
1213 "github.com/bnema/vev/internal/protocol"
1314 "github.com/bnema/vev/internal/protocol/catalogue"
1415 "github.com/bnema/vev/internal/usecase/keys"
@@ -75,7 +76,6 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
7576 rt.pickerPreview = nil
7677 rt.picker = model
7778 rt.pickerGeneration++
78- instance := remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: model}
7979 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
8080 rt.pickerIntent = intent
8181 rt.pickerSource = source
@@ -85,10 +85,6 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.
8585 d.teardownPreviewSubscription(ac, previous, previousGeneration)
8686 d.registerPreviewForSelection(ac)
8787 d.invalidateRender(sess, ac, true, "picker.go")
88- if rt.beforeRemotePickerRegistration != nil {
89- rt.beforeRemotePickerRegistration()
90- }
91- d.remoteDiscoveryOpened(instance.discoveryInstance())
9288 }
9389
9490 // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
@@ -167,13 +163,16 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
167163 })
168164 }
169165
170- catalog := d.remoteCatalogSnapshot()
171- sortRemoteCatalog(catalog, d.remoteHostRanks())
166+ directory := d.remoteDirectorySnapshot()
167+ monitored := d.remoteDirectory != nil
168+ hosts := append([]ports.RemoteHostSnapshot(nil), directory.Hosts...)
169+ sortDirectoryHosts(hosts)
170+ now := d.daemonNow()
172171
173172 catalogRows := 0
174- for _, host := range catalog {
175- catalogRows += len(host.entry.Sessions)
176- if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) {
173+ for _, host := range hosts {
174+ catalogRows += len(host.Sessions)
175+ if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable {
177176 catalogRows++
178177 }
179178 }
@@ -187,33 +186,43 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session
187186 views = append(views, view)
188187 }
189188 publishedRemote := false
190- for _, host := range catalog {
189+ for _, host := range hosts {
191190 publishedForHost := 0
192- for _, session := range host.entry.Sessions {
193- key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}
191+ for _, session := range host.Sessions {
192+ key := domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name}
194193 if key.Validate() != nil {
195194 continue
196195 }
197- view := remotePickerView(key, session, host.status, host.entry.FetchedAt)
196+ view := remotePickerView(key, session, host, now)
198197 if grouped {
199198 view.HideRemoteOrigin = true
200199 if publishedForHost == 0 {
201- view.Section = "REMOTE " + host.entry.Host
200+ view.Section = "REMOTE " + host.Endpoint
202201 }
203202 }
204203 views = append(views, view)
205204 publishedForHost++
206205 publishedRemote = true
207206 }
208- if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) {
209- view := remotePickerHostView(host.entry.Host, host.status)
207+ if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable {
208+ view := remotePickerHostView(host, now)
210209 if grouped {
211- view.Section = "REMOTE " + host.entry.Host
210+ view.Section = "REMOTE " + host.Endpoint
212211 }
213212 views = append(views, view)
214213 publishedRemote = true
215214 }
216215 }
216+ // A nil directory means remote monitoring is not installed at all:
217+ // only an installed-but-unpublished monitor reads as "checking".
218+ if monitored && !directory.Initialized {
219+ view := remotePickerCheckingView()
220+ if grouped {
221+ view.Section = "REMOTE"
222+ }
223+ views = append(views, view)
224+ publishedRemote = true
225+ }
217226 for i, s := range stopped {
218227 createdAt := s.createdAt
219228 view := picker.SessionView{
@@ -253,7 +262,7 @@ func attentionSuffix(label string) string {
253262 func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Target) {
254263 reason := target.UnavailableReason
255264 if reason == "" {
256- reason = "identity_changed"
265+ reason = domain.RemoteReasonIdentityChanged
257266 }
258267 message := "Remote session unavailable"
259268 if target.RemoteTarget != nil {
@@ -267,21 +276,23 @@ func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Targ
267276
268277 func remotePickerReasonText(reason string) string {
269278 switch reason {
270- case "catalog_stale":
279+ case domain.RemoteReasonCatalogStale:
271280 return "catalog stale"
272- case "host_unreachable":
281+ case domain.RemoteReasonHostUnreachable:
273282 return "host unreachable"
274- case "version_mismatch":
283+ case domain.RemoteReasonVersionMismatch:
275284 return "version mismatch"
276- case "session_stopped":
285+ case domain.RemoteReasonSessionStopped:
277286 return "session stopped"
278- case "session_broken":
287+ case domain.RemoteReasonSessionBroken:
279288 return "session broken"
280- case "malformed":
289+ case domain.RemoteReasonMalformed:
281290 return "catalog malformed"
282- case "refreshing":
291+ case domain.RemoteReasonAuthFailure:
292+ return "authentication failed"
293+ case domain.RemoteReasonRefreshing:
283294 return "refreshing"
284- case "identity_changed":
295+ case domain.RemoteReasonIdentityChanged:
285296 return "session identity changed"
286297 default:
287298 return "unavailable"
@@ -314,7 +325,6 @@ func pickerSearchSlashIndex(data, pending []byte) int {
314325 func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
315326 rt := ac.overlays
316327 var previewGeneration uint64
317- var instance remotePickerInstance
318328 return listInputState{
319329 pending: &rt.pickerPending,
320330 esc: &rt.pickerESC,
@@ -324,7 +334,6 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
324334 unlock: rt.pickerMu.Unlock,
325335 active: func() bool { return rt.picker != nil },
326336 closeLocked: func() {
327- instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}
328337 rt.picker = nil
329338 rt.pickerIntent = pickerNavigate
330339 rt.pickerSource = moveSourceLocator{}
@@ -332,7 +341,6 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
332341 },
333342 afterClose: func() {
334343 d.clearPreviewGeneration(ac, previewGeneration)
335- d.remoteDiscoveryClosed(instance.discoveryInstance())
336344 if sess := ac.currentAttachmentSession(); sess != nil {
337345 d.invalidateRender(sess, ac, true, "picker.go")
338346 }
@@ -550,7 +558,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
550558 ac.overlays.pickerMu.Lock()
551559 // Selection may have changed while the target was resolved.
552560 selected, stillSelected := ac.overlays.picker.Selected()
553- valid := ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerIntent == intent && stillSelected && pickerTargetsEqual(selected, target)
561+ valid := ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerIntent == intent && stillSelected && pickerRouteTargetsEqual(selected, target)
554562 if valid {
555563 ac.overlays.pickerPreview = next
556564 ac.overlays.pickerPreviewSession = targetSess
@@ -633,7 +641,7 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ
633641 matching := false
634642 if ac.overlays.pickerPreviewGeneration == generation && ac.overlays.picker != nil {
635643 selected, stillSelected := ac.overlays.picker.Selected()
636- matching = stillSelected && pickerTargetsEqual(selected, target)
644+ matching = stillSelected && pickerRouteTargetsEqual(selected, target)
637645 if matching {
638646 ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, "remote preview unavailable")
639647 }
@@ -659,7 +667,7 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ
659667 return
660668 }
661669 selected, stillSelected := ac.overlays.picker.Selected()
662- if !stillSelected || !pickerTargetsEqual(selected, target) {
670+ if !stillSelected || !pickerRouteTargetsEqual(selected, target) {
663671 ac.overlays.pickerMu.Unlock()
664672 return
665673 }
@@ -819,7 +827,6 @@ func (d *Daemon) closePickerIfCurrentRefresh(ac *attachedClient, model *picker.M
819827 ac.overlays.pickerMu.Unlock()
820828 return false
821829 }
822- instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}
823830 ac.overlays.picker = nil
824831 ac.overlays.pickerTitle = ""
825832 ac.overlays.pickerIntent = pickerNavigate
@@ -829,16 +836,16 @@ func (d *Daemon) closePickerIfCurrentRefresh(ac *attachedClient, model *picker.M
829836 previewGeneration := ac.overlays.pickerPreviewGeneration
830837 ac.overlays.pickerMu.Unlock()
831838 d.clearPreviewGeneration(ac, previewGeneration)
832- if instance.model != nil {
833- d.remoteDiscoveryClosed(instance.discoveryInstance())
834- }
835839 return true
836840 }
837841
838-func pickerTargetsEqual(left, right picker.Target) bool {
842+// pickerRouteTargetsEqual compares the exact route identity of two picker
843+// targets, ignoring presentation fields such as UnavailableReason that
844+// render around the route on every observation.
845+func pickerRouteTargetsEqual(left, right picker.Target) bool {
839846 if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||
840847 left.RemoteHost != right.RemoteHost || left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped ||
841- left.UnavailableReason != right.UnavailableReason || !remoteKeysEqual(left.RemoteKey, right.RemoteKey) ||
848+ !remoteKeysEqual(left.RemoteKey, right.RemoteKey) ||
842849 !remoteTargetsEqual(left.RemoteTarget, right.RemoteTarget) {
843850 return false
844851 }
@@ -1107,29 +1114,19 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(effect *attachmentEffect, t
11071114 return nil
11081115 }
11091116
1110-func (d *Daemon) remoteCatalogEntryLocked(host string) (catalogue.RemoteCatalogCacheEntry, bool) {
1111- if d.remoteCatalog.status[host] != remoteHostFresh {
1112- return catalogue.RemoteCatalogCacheEntry{}, false
1113- }
1114- entry, ok := d.remoteCatalog.cache[host]
1115- if !ok || remoteCatalogExpired(entry.FetchedAt, d.clock.Now()) {
1116- d.remoteCatalog.status[host] = remoteHostStale
1117- return catalogue.RemoteCatalogCacheEntry{}, false
1118- }
1119- return entry, true
1120-}
1121-
1117+// remoteCatalogTargetReady validates an exact remote destination against
1118+// the latest known inventory at any age. Freshness is presentation
1119+// information, not attach authority: valid cached targets are attempted and
1120+// rejected precisely at the destination, never declared nonexistent by age.
11221121 func (d *Daemon) remoteCatalogTargetReady(target domain.RemoteSessionTarget) bool {
11231122 if d == nil || target.Validate() != nil {
11241123 return false
11251124 }
1126- d.remoteCatalog.mu.Lock()
1127- defer d.remoteCatalog.mu.Unlock()
1128- entry, ok := d.remoteCatalogEntryLocked(target.Endpoint)
1129- if !ok {
1125+ host, ok := d.remoteDirectorySnapshot().Find(target.Endpoint)
1126+ if !ok || !host.InventoryKnown {
11301127 return false
11311128 }
1132- for _, session := range entry.Sessions {
1129+ for _, session := range host.Sessions {
11331130 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
11341131 continue
11351132 }
M internal/usecase/daemon/remote_attach_test.go +15 -30
@@ -20,7 +20,7 @@ func remoteLifecycleForTest() domain.SessionLifecycleID {
2020 }
2121
2222 func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) {
23- d := newRemotePickerDaemon(nil)
23+ d := newRemotePickerDaemon()
2424 source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source")
2525 target, _, _ := addRemoteRefreshPickerOwner(t, d, "target")
2626 target.incarnation = remoteLifecycleForTest()
@@ -41,7 +41,7 @@ func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) {
4141 }
4242
4343 func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) {
44- d := newRemotePickerDaemon(nil)
44+ d := newRemotePickerDaemon()
4545 source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source")
4646 lifecycle := remoteLifecycleForTest()
4747 d.inactive["stopped"] = inactiveSession{
@@ -74,16 +74,11 @@ func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) {
7474 }
7575
7676 func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) {
77- d := newRemotePickerDaemon(nil)
77+ d := newRemotePickerDaemon()
7878 lifecycle := remoteLifecycleForTest()
79- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
80- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
81- LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
82- }},
83- }})
84- d.remoteCatalog.mu.Lock()
85- d.remoteCatalog.status["arch"] = remoteHostFresh
86- d.remoteCatalog.mu.Unlock()
79+ seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(10, 0), catalogue.RemoteCatalogSession{
80+ LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
81+ }))
8782 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
8883 token := sess.captureAttachmentCapability(ac, ac.transport())
8984 effect, admitted := ac.beginAttachmentEffect(token)
@@ -115,17 +110,12 @@ func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) {
115110 }},
116111 } {
117112 t.Run(test.name, func(t *testing.T) {
118- d := newRemotePickerDaemon(nil)
113+ d := newRemotePickerDaemon()
119114 lifecycle := remoteLifecycleForTest()
120- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
121- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
122- LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
123- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
124- }},
125- }})
126- d.remoteCatalog.mu.Lock()
127- d.remoteCatalog.status["arch"] = remoteHostFresh
128- d.remoteCatalog.mu.Unlock()
115+ seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(10, 0), catalogue.RemoteCatalogSession{
116+ LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
117+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
118+ }))
129119 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
130120 token := sess.captureAttachmentCapability(ac, ac.transport())
131121 effect, admitted := ac.beginAttachmentEffect(token)
@@ -151,18 +141,13 @@ func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) {
151141 }
152142
153143 func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) {
154- d := newRemotePickerDaemon(nil)
144+ d := newRemotePickerDaemon()
155145 lifecycle := remoteLifecycleForTest()
156146 cachedLifecycle := lifecycle
157147 cachedLifecycle[0]++
158- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
159- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
160- LifecycleID: cachedLifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
161- }},
162- }})
163- d.remoteCatalog.mu.Lock()
164- d.remoteCatalog.status["arch"] = remoteHostFresh
165- d.remoteCatalog.mu.Unlock()
148+ seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(10, 0), catalogue.RemoteCatalogSession{
149+ LifecycleID: cachedLifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
150+ }))
166151 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
167152 token := sess.captureAttachmentCapability(ac, ac.transport())
168153 effect, admitted := ac.beginAttachmentEffect(token)
D internal/usecase/daemon/remote_catalog_state.go +0 -121
@@ -1,121 +0,0 @@
1-package daemon
2-
3-import (
4- "context"
5- "slices"
6- "sort"
7- "sync"
8- "time"
9-
10- "github.com/bnema/vev/internal/protocol/catalogue"
11-)
12-
13-type remoteHostStatus uint8
14-
15-const (
16- remoteHostCached remoteHostStatus = iota
17- remoteHostRefreshing
18- remoteHostFresh
19- remoteHostStale
20- remoteHostUnreachable
21- remoteHostVersionMismatch
22- remoteHostMalformed
23-)
24-
25-type remoteDiscoveryConsumerKind uint8
26-
27-const (
28- remoteDiscoveryPicker remoteDiscoveryConsumerKind = 1 << iota
29- remoteDiscoveryPalette
30-)
31-
32-// remoteCatalogState owns cache-derived discovery state. Its mutex never
33-// covers cache or transport I/O; writeMu serializes complete cache writes.
34-type remoteCatalogState struct {
35- mu sync.Mutex
36- cache map[string]catalogue.RemoteCatalogCacheEntry
37- status map[string]remoteHostStatus
38- failure map[string]time.Time
39- refresh uint64
40- cancel context.CancelFunc
41- consumers map[*attachedClient]remoteDiscoveryConsumerKind
42- writeMu sync.Mutex
43-}
44-
45-func newRemoteCatalogState() remoteCatalogState {
46- return remoteCatalogState{
47- cache: make(map[string]catalogue.RemoteCatalogCacheEntry),
48- status: make(map[string]remoteHostStatus),
49- failure: make(map[string]time.Time),
50- consumers: make(map[*attachedClient]remoteDiscoveryConsumerKind),
51- }
52-}
53-
54-func cloneRemoteCatalogEntry(entry catalogue.RemoteCatalogCacheEntry) catalogue.RemoteCatalogCacheEntry {
55- sessions := make([]catalogue.RemoteCatalogSession, len(entry.Sessions))
56- for i, session := range entry.Sessions {
57- sessions[i] = session
58- if tabs := catalogue.CatalogTabs(session); tabs != nil {
59- sessions[i].Tabs = slices.Clone(tabs)
60- }
61- }
62- return catalogue.RemoteCatalogCacheEntry{
63- Host: entry.Host,
64- FetchedAt: entry.FetchedAt,
65- Sessions: sessions,
66- }
67-}
68-
69-func (s *remoteCatalogState) replaceCache(entries []catalogue.RemoteCatalogCacheEntry) {
70- if s == nil {
71- return
72- }
73- s.mu.Lock()
74- defer s.mu.Unlock()
75- s.cache = make(map[string]catalogue.RemoteCatalogCacheEntry, len(entries))
76- s.status = make(map[string]remoteHostStatus, len(entries))
77- s.failure = make(map[string]time.Time)
78- for _, entry := range entries {
79- s.cache[entry.Host] = cloneRemoteCatalogEntry(entry)
80- s.status[entry.Host] = remoteHostCached
81- }
82-}
83-
84-func (s *remoteCatalogState) cacheSnapshot() []catalogue.RemoteCatalogCacheEntry {
85- if s == nil {
86- return nil
87- }
88- s.mu.Lock()
89- defer s.mu.Unlock()
90- entries := make([]catalogue.RemoteCatalogCacheEntry, 0, len(s.cache))
91- for _, entry := range s.cache {
92- entries = append(entries, cloneRemoteCatalogEntry(entry))
93- }
94- sort.Slice(entries, func(i, j int) bool { return entries[i].Host < entries[j].Host })
95- return entries
96-}
97-
98-func (d *Daemon) loadRemoteCatalogCache() {
99- if d == nil || d.remoteCatalogCache == nil {
100- return
101- }
102- entries, err := d.remoteCatalogCache.Load()
103- if err != nil {
104- d.log.Warn("loading remote catalog cache failed; starting empty", "err", err)
105- return
106- }
107- d.remoteCatalog.replaceCache(entries)
108-}
109-
110-func (d *Daemon) persistRemoteCatalogCache() {
111- if d == nil || d.remoteCatalogCache == nil {
112- return
113- }
114- d.remoteCatalog.writeMu.Lock()
115- defer d.remoteCatalog.writeMu.Unlock()
116-
117- entries := d.remoteCatalog.cacheSnapshot()
118- if err := d.remoteCatalogCache.Store(entries); err != nil {
119- d.log.Warn("storing remote catalog cache failed", "err", err)
120- }
121-}
D internal/usecase/daemon/remote_catalog_state_test.go +0 -128
@@ -1,128 +0,0 @@
1-package daemon
2-
3-import (
4- "errors"
5- "io"
6- "log/slog"
7- "sync"
8- "testing"
9- "time"
10-
11- "github.com/bnema/vev/internal/protocol/catalogue"
12- "github.com/stretchr/testify/require"
13-)
14-
15-type catalogCacheStub struct {
16- load func() ([]catalogue.RemoteCatalogCacheEntry, error)
17- store func([]catalogue.RemoteCatalogCacheEntry) error
18-}
19-
20-func (s catalogCacheStub) Load() ([]catalogue.RemoteCatalogCacheEntry, error) {
21- if s.load == nil {
22- return nil, nil
23- }
24- return s.load()
25-}
26-
27-func (s catalogCacheStub) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
28- if s.store == nil {
29- return nil
30- }
31- return s.store(entries)
32-}
33-
34-func TestRemoteCatalogCacheStartup(t *testing.T) {
35- now := time.Unix(0, 42)
36- tests := []struct {
37- name string
38- load func() ([]catalogue.RemoteCatalogCacheEntry, error)
39- wantCache map[string]catalogue.RemoteCatalogCacheEntry
40- wantStatus map[string]remoteHostStatus
41- }{
42- {
43- name: "loads cached entries",
44- load: func() ([]catalogue.RemoteCatalogCacheEntry, error) {
45- return []catalogue.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: now, Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}}}}, nil
46- },
47- wantCache: map[string]catalogue.RemoteCatalogCacheEntry{"arch": {Host: "arch", FetchedAt: now, Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}}}},
48- wantStatus: map[string]remoteHostStatus{"arch": remoteHostCached},
49- },
50- {
51- name: "invalid cache starts empty",
52- load: func() ([]catalogue.RemoteCatalogCacheEntry, error) {
53- return nil, errors.New("invalid cache")
54- },
55- wantCache: map[string]catalogue.RemoteCatalogCacheEntry{},
56- wantStatus: map[string]remoteHostStatus{},
57- },
58- }
59-
60- for _, tt := range tests {
61- t.Run(tt.name, func(t *testing.T) {
62- d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, catalogCacheStub{load: tt.load}))
63-
64- d.remoteCatalog.mu.Lock()
65- defer d.remoteCatalog.mu.Unlock()
66- require.Equal(t, tt.wantCache, d.remoteCatalog.cache)
67- require.Equal(t, tt.wantStatus, d.remoteCatalog.status)
68- })
69- }
70-}
71-
72-func TestRemoteCatalogCacheStoreSerializesLockFreeIO(t *testing.T) {
73- var d *Daemon
74- entered := make(chan struct{}, 2)
75- release := make(chan struct{})
76- var active, maxActive int
77- var activeMu sync.Mutex
78-
79- cache := catalogCacheStub{store: func(entries []catalogue.RemoteCatalogCacheEntry) error {
80- if !d.mu.TryLock() {
81- return errors.New("daemon lock held during cache I/O")
82- }
83- d.mu.Unlock()
84- if !d.remoteCatalog.mu.TryLock() {
85- return errors.New("remote catalog lock held during cache I/O")
86- }
87- d.remoteCatalog.mu.Unlock()
88-
89- activeMu.Lock()
90- active++
91- if active > maxActive {
92- maxActive = active
93- }
94- activeMu.Unlock()
95- entered <- struct{}{}
96- <-release
97- activeMu.Lock()
98- active--
99- activeMu.Unlock()
100- return nil
101- }}
102- d = New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(nil, nil, cache))
103- d.remoteCatalog.mu.Lock()
104- d.remoteCatalog.cache["arch"] = catalogue.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 1), Sessions: []catalogue.RemoteCatalogSession{{Name: "one", State: "up"}}}
105- d.remoteCatalog.mu.Unlock()
106-
107- done := make(chan struct{}, 2)
108- go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()
109- <-entered
110- d.remoteCatalog.mu.Lock()
111- d.remoteCatalog.cache["arch"] = catalogue.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(0, 2), Sessions: []catalogue.RemoteCatalogSession{{Name: "two", State: "up"}}}
112- d.remoteCatalog.mu.Unlock()
113- go func() { d.persistRemoteCatalogCache(); done <- struct{}{} }()
114-
115- select {
116- case <-entered:
117- t.Fatal("concurrent cache stores were not serialized")
118- case <-time.After(10 * time.Millisecond):
119- }
120- close(release)
121- <-entered
122- <-done
123- <-done
124-
125- activeMu.Lock()
126- defer activeMu.Unlock()
127- require.Equal(t, 1, maxActive)
128-}
M internal/usecase/daemon/remote_create_environment_test.go +2 -6
@@ -12,17 +12,13 @@ import (
1212 "github.com/bnema/vev/internal/ports"
1313 portsmocks "github.com/bnema/vev/internal/ports/mocks"
1414 "github.com/bnema/vev/internal/protocol"
15- "github.com/bnema/vev/internal/protocol/catalogue"
1615 "github.com/bnema/vev/internal/protocol/wire"
1716 "github.com/bnema/vev/internal/usecase/palette"
1817 )
1918
2019 func TestPaletteRemoteCreateUsesDaemonEnvironment(t *testing.T) {
21- local := newRemotePickerDaemon(&remoteRefreshHostStore{hosts: []string{"remote.example"}})
22- local.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{Host: "remote.example", FetchedAt: time.Unix(10, 0)}})
23- local.remoteCatalog.mu.Lock()
24- local.remoteCatalog.status["remote.example"] = remoteHostFresh
25- local.remoteCatalog.mu.Unlock()
20+ local := newRemotePickerDaemon()
21+ seedRemoteDirectory(t, local, reachableDirectoryHost("remote.example", time.Unix(10, 0)))
2622 sess, ac, sends := addRemoteRefreshPickerOwner(t, local, "local")
2723 effect := beginRecentRoutePaletteEffect(t, local, sess, ac)
2824 result := palette.NewCreateSessionDestination(palette.CreateSessionOnRemoteHost, "remote.example", "remote.example", protocol.RouteRef{}, 0)
A internal/usecase/daemon/remote_directory_view.go +92 -0
@@ -0,0 +1,92 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "sort"
6+
7+ "github.com/bnema/vev/internal/ports"
8+)
9+
10+// remoteDirectorySnapshot returns the newest directory publication, or an
11+// empty uninitialized snapshot when monitoring is disabled. It performs no
12+// I/O and never waits for the service loop: empty state is valid and
13+// immediately renderable.
14+func (d *Daemon) remoteDirectorySnapshot() ports.RemoteDirectorySnapshot {
15+ if d == nil || d.remoteDirectory == nil {
16+ return ports.RemoteDirectorySnapshot{Hosts: []ports.RemoteHostSnapshot{}}
17+ }
18+ return d.remoteDirectory.Snapshot()
19+}
20+
21+// sortDirectoryHosts orders hosts by registry rank, breaking ties by
22+// endpoint. Snapshot slice order is alphabetical; Rank carries registry
23+// (pinned-then-learned) order for presentation.
24+func sortDirectoryHosts(hosts []ports.RemoteHostSnapshot) {
25+ sort.Slice(hosts, func(i, j int) bool {
26+ if hosts[i].Rank != hosts[j].Rank {
27+ return hosts[i].Rank < hosts[j].Rank
28+ }
29+ return hosts[i].Endpoint < hosts[j].Endpoint
30+ })
31+}
32+
33+// watchRemoteDirectory relays directory publications to open remote views.
34+// It is not an attachment: it claims no geometry, history, or keepalive,
35+// and losing it never affects session lifetime.
36+func (d *Daemon) watchRemoteDirectory(ctx context.Context) {
37+ directory := d.remoteDirectory
38+ if d == nil || directory == nil {
39+ return
40+ }
41+ sub := directory.Subscribe()
42+ defer sub.Close()
43+ for {
44+ select {
45+ case <-ctx.Done():
46+ return
47+ case <-sub.Changed():
48+ d.refreshRemoteDirectoryViews()
49+ }
50+ }
51+}
52+
53+// refreshRemoteDirectoryViews rebuilds only open remote views from the
54+// newest snapshot, then invalidates through existing attachment generations
55+// after releasing state locks. Overlay entry builds from the latest snapshot
56+// immediately; overlay exit releases view state only.
57+func (d *Daemon) refreshRemoteDirectoryViews() {
58+ if d == nil {
59+ return
60+ }
61+ d.mu.Lock()
62+ sessions := d.sessionsSnapshotLocked()
63+ d.mu.Unlock()
64+ for _, sess := range sessions {
65+ if sess == nil {
66+ continue
67+ }
68+ for _, ac := range sess.snapshotAttachments() {
69+ d.refreshRemoteDirectoryViewsFor(ac)
70+ }
71+ }
72+}
73+
74+func (d *Daemon) refreshRemoteDirectoryViewsFor(ac *attachedClient) {
75+ if ac == nil || ac.overlays == nil {
76+ return
77+ }
78+ pickerOpen := ac.overlays.pickerActive()
79+ paletteOpen := ac.overlays.paletteActive()
80+ if !pickerOpen && !paletteOpen {
81+ return
82+ }
83+ if pickerOpen {
84+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
85+ }
86+ if paletteOpen {
87+ d.refreshPalette(ac)
88+ }
89+ if entry := ac.currentAttachmentSession(); entry != nil {
90+ d.invalidateRender(entry, ac, true, "remote_directory_view.go")
91+ }
92+}
A internal/usecase/daemon/remote_lifecycle_regression_test.go +176 -0
@@ -0,0 +1,176 @@
1+package daemon
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+
9+ "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/protocol/catalogue"
11+ "github.com/bnema/vev/internal/protocol/wire"
12+ "github.com/bnema/vev/internal/usecase/palette"
13+)
14+
15+// findRemoteCNSDestination returns the palette CNS destination result for an
16+// exact remote endpoint, mirroring what the overlay offers the user.
17+func findRemoteCNSDestination(results []palette.Result, endpoint string) (palette.Result, bool) {
18+ for _, result := range results {
19+ _, kind, _, resultEndpoint, _, ok := result.CreateSessionDestination()
20+ if ok && kind == palette.CreateSessionOnRemoteHost && resultEndpoint == endpoint {
21+ return result, true
22+ }
23+ }
24+ return palette.Result{}, false
25+}
26+
27+// seedDirectoryRemoteCatalog installs one reachable directory host for
28+// endpoint with a single live session at the daemon clock's current time.
29+func seedDirectoryRemoteCatalog(t *testing.T, d *Daemon, endpoint string) {
30+ t.Helper()
31+ var now time.Time
32+ if d.clock != nil {
33+ now = d.clock.Now()
34+ } else {
35+ now = time.Unix(1_000, 0)
36+ }
37+ seedRemoteDirectory(t, d, reachableDirectoryHost(endpoint, now, catalogue.RemoteCatalogSession{
38+ LifecycleID: domain.SessionLifecycleID{77}, Name: "work", State: catalogue.RemoteCatalogSessionUp,
39+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-work", Index: 0, Name: "shell"}}, ActiveTabID: "tab-work",
40+ }))
41+}
42+
43+// TestPaletteRemoteCNSDestinationSurvivesStaleOrCheckingCatalog is the P1.1
44+// regression for the reported symptom: palette CNS creation targeting a
45+// configured remote fails with "that destination is no longer available"
46+// (errCreateDestinationUnavailable) whenever catalogue freshness lapses or an
47+// observation is in flight.
48+//
49+// Product requirement under test: a registered host remains a valid,
50+// attemptable CNS destination while its inventory is stale or checking.
51+// Freshness is presentation information, not creation authority; exact
52+// destination validation still applies at the destination itself.
53+func TestPaletteRemoteCNSDestinationSurvivesStaleOrCheckingCatalog(t *testing.T) {
54+ const endpoint = "user@arch"
55+
56+ t.Run("listed while fresh", func(t *testing.T) {
57+ p, release := newBlockingPTY(t)
58+ defer release()
59+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
60+ d.clock = &remotePickerClock{now: time.Unix(1_000, 0)}
61+ seedDirectoryRemoteCatalog(t, d, endpoint)
62+
63+ results := d.paletteResults(sess, nil, testRecentRouteSnapshot())
64+ _, found := findRemoteCNSDestination(results, endpoint)
65+ require.True(t, found, "fresh registered destination must be offered")
66+ })
67+
68+ t.Run("stale age keeps registered destination", func(t *testing.T) {
69+ p, release := newBlockingPTY(t)
70+ defer release()
71+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
72+ clock := &remotePickerClock{now: time.Unix(1_000, 0)}
73+ d.clock = clock
74+ seedDirectoryRemoteCatalog(t, d, endpoint)
75+
76+ // Let the 30s inventory TTL lapse with no new observation. The
77+ // seeded snapshot keeps its original success time, so the daemon
78+ // clock now observes it as stale presentation.
79+ clock.Advance(31 * time.Second)
80+
81+ results := d.paletteResults(sess, nil, testRecentRouteSnapshot())
82+ _, found := findRemoteCNSDestination(results, endpoint)
83+ require.True(t, found, "stale registered destination must remain offered, not hidden by age alone")
84+ })
85+
86+ t.Run("in-flight observation keeps registered destination", func(t *testing.T) {
87+ p, release := newBlockingPTY(t)
88+ defer release()
89+ d, sess, _, _ := newManualSessionWithPTYs(t, p)
90+ d.clock = &remotePickerClock{now: time.Unix(1_000, 0)}
91+ seedDirectoryRemoteCatalog(t, d, endpoint)
92+
93+ // Mark the seeded host checking: an observation is in flight, but
94+ // the last-known inventory and registration remain published.
95+ stub, ok := d.remoteDirectory.(*stubRemoteDirectory)
96+ require.True(t, ok, "regression harness seeds a stub directory")
97+ stub.mu.Lock()
98+ stub.snapshot.Hosts[0].Checking = true
99+ stub.mu.Unlock()
100+
101+ results := d.paletteResults(sess, nil, testRecentRouteSnapshot())
102+ _, found := findRemoteCNSDestination(results, endpoint)
103+ require.True(t, found, "checking registered destination must remain offered while observation is in flight")
104+ })
105+
106+ t.Run("prompt submission after state change is accepted", func(t *testing.T) {
107+ const second = "user@mule"
108+ p, release := newBlockingPTY(t)
109+ defer release()
110+ d, sess, ac, sends := newManualSessionWithPTYs(t, p)
111+ clock := &remotePickerClock{now: time.Unix(1_000, 0)}
112+ d.clock = clock
113+ seedDirectoryRemoteCatalog(t, d, endpoint)
114+ stub, ok := d.remoteDirectory.(*stubRemoteDirectory)
115+ require.True(t, ok, "regression harness seeds a stub directory")
116+ stub.mu.Lock()
117+ stub.snapshot.Hosts = append(stub.snapshot.Hosts, reachableDirectoryHost(second, clock.Now(), catalogue.RemoteCatalogSession{
118+ LifecycleID: domain.SessionLifecycleID{78}, Name: "other", State: catalogue.RemoteCatalogSessionUp,
119+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-other", Index: 0, Name: "shell"}}, ActiveTabID: "tab-other",
120+ }))
121+ for i := range stub.snapshot.Hosts {
122+ stub.snapshot.Hosts[i].Rank = i
123+ if stub.snapshot.Hosts[i].DisplayOrigin == "" {
124+ stub.snapshot.Hosts[i].DisplayOrigin = domain.RemoteDisplayOrigin(stub.snapshot.Hosts[i].Endpoint)
125+ }
126+ }
127+ stub.mu.Unlock()
128+
129+ // Real handler flow with an attachment effect: open the palette
130+ // and type an explicit CNS request. Destination mode needs at
131+ // least two destinations; the first-ranked row is the target
132+ // endpoint with the name bound.
133+ token := beginRecentRoutePaletteEffect(t, d, sess, ac)
134+ d.handleInputForAttachment(token, []byte("\x1b "))
135+ awaitFrame(t, sends, wire.MsgOutput)
136+ d.handleInputForAttachment(token, []byte("CNS workremote"))
137+ // Destination mode lists candidates without a selection until the
138+ // user moves; Down selects the first-ranked row (the target).
139+ d.handleInputForAttachment(token, []byte("\x1b[B"))
140+ selected, ok := ac.overlays.palette.Selected()
141+ require.True(t, ok, "palette must offer a selection")
142+ name, kind, _, selectedEndpoint, _, isDestination := selected.CreateSessionDestination()
143+ if !(isDestination && kind == palette.CreateSessionOnRemoteHost && selectedEndpoint == endpoint) {
144+ matches := ac.overlays.palette.Matches()
145+ texts := make([]string, 0, len(matches))
146+ for _, match := range matches {
147+ texts = append(texts, match.Result.DisplayText())
148+ }
149+ t.Fatalf("query must select the remote CNS destination, got %q (query %q, matches %q)", selected.DisplayText(), ac.overlays.palette.Query(), texts)
150+ }
151+ require.Equal(t, "workremote", name)
152+
153+ // State changes after selection: the inventory TTL lapses before
154+ // the destination is submitted. Registration still authorizes the
155+ // submit; the destination validates the exact identity itself.
156+ clock.Advance(31 * time.Second)
157+
158+ d.handleInputForAttachment(token, []byte("\r"))
159+ deadline := time.After(2 * time.Second)
160+ for {
161+ select {
162+ case frame := <-sends:
163+ if frame.Type == wire.MsgAttachTarget {
164+ target, err := wire.UnmarshalAttachTarget(frame.Payload)
165+ require.NoError(t, err)
166+ require.Equal(t, endpoint, target.Endpoint)
167+ require.Equal(t, "workremote", target.Session)
168+ return
169+ }
170+ case <-deadline:
171+ t.Fatalf("registered destination submit after TTL lapse must still reach %s, not fail with %q (feedback %q, palette open=%v)",
172+ endpoint, errCreateDestinationUnavailable, ac.overlays.paletteFeedback, ac.overlays.paletteActive())
173+ }
174+ }
175+ })
176+}
A internal/usecase/daemon/remote_monitor_lifecycle_test.go +239 -0
@@ -0,0 +1,239 @@
1+package daemon
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "sync"
8+ "testing"
9+ "time"
10+
11+ "github.com/stretchr/testify/require"
12+
13+ "github.com/bnema/vev/internal/adapters/clock"
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/internal/protocol"
17+ "github.com/bnema/vev/internal/protocol/wire"
18+)
19+
20+// stubRemoteDirectory is a passive RemoteDirectory: every method is safe to
21+// call from any goroutine and performs no I/O.
22+type stubRemoteDirectory struct {
23+ mu sync.Mutex
24+ snapshot ports.RemoteDirectorySnapshot
25+ snapshots int
26+}
27+
28+func (s *stubRemoteDirectory) Snapshot() ports.RemoteDirectorySnapshot {
29+ s.mu.Lock()
30+ defer s.mu.Unlock()
31+ s.snapshots++
32+ return s.snapshot.Clone()
33+}
34+
35+func (s *stubRemoteDirectory) Subscribe() ports.RemoteDirectorySubscription {
36+ return &stubDirectorySubscription{changed: make(chan struct{}, 1)}
37+}
38+
39+func (s *stubRemoteDirectory) RequestReconcile(endpoint string) {}
40+func (s *stubRemoteDirectory) RegistryChanged() {}
41+
42+type stubDirectorySubscription struct {
43+ changed chan struct{}
44+ once sync.Once
45+}
46+
47+func (s *stubDirectorySubscription) Changed() <-chan struct{} { return s.changed }
48+func (s *stubDirectorySubscription) Close() {
49+ s.once.Do(func() { close(s.changed) })
50+}
51+
52+// scriptRemoteMonitor records runner start/stop. When hang is set it ignores
53+// cancellation, modeling an uncooperative worker for bounded-join tests.
54+type scriptRemoteMonitor struct {
55+ started chan struct{}
56+ stopped chan struct{}
57+ hang bool
58+}
59+
60+func (m *scriptRemoteMonitor) run(ctx context.Context) error {
61+ close(m.started)
62+ if m.hang {
63+ select {}
64+ }
65+ <-ctx.Done()
66+ close(m.stopped)
67+ return nil
68+}
69+
70+func newMonitorServeHarness(t *testing.T, monitor *scriptRemoteMonitor) (*Daemon, *mockServerListener, chan wire.Transport, chan wire.Frame, func()) {
71+ t.Helper()
72+ p, releasePTY := newBlockingPTY(t)
73+ tr, sends, _ := newConn(t, mustHello(protocol.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
74+
75+ l := newMockServerListener(t)
76+ connCh := make(chan wire.Transport, 1)
77+ connCh <- tr
78+ closed := make(chan struct{})
79+ var once sync.Once
80+ l.EXPECT().Accept().RunAndReturn(func() (wire.Transport, error) {
81+ select {
82+ case c := <-connCh:
83+ return c, nil
84+ case <-closed:
85+ return nil, io.EOF
86+ }
87+ }).Maybe()
88+ l.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
89+ l.EXPECT().Addr().Return("mock").Maybe()
90+
91+ // No remote ports reach the daemon: constructors, socket publication
92+ // and first paint are structurally I/O-free. All remote work flows
93+ // through the monitor runner below.
94+ d := New(newFactory(t, p), stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)),
95+ WithRemoteMonitor(&stubRemoteDirectory{}, monitor.run),
96+ )
97+ return d, l, connCh, sends, releasePTY
98+}
99+
100+func TestDaemonNewWithMonitorPerformsNoIO(t *testing.T) {
101+ started := make(chan struct{}, 1)
102+ d := New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)),
103+ WithRemoteMonitor(&stubRemoteDirectory{}, func(ctx context.Context) error {
104+ started <- struct{}{}
105+ return nil
106+ }),
107+ )
108+ require.NotNil(t, d)
109+ // The constructor only stores the runner; Serve owns starting it.
110+ select {
111+ case <-started:
112+ t.Fatal("monitor runner started in the constructor")
113+ case <-time.After(50 * time.Millisecond):
114+ }
115+}
116+
117+// TestServeStartsMonitorWithoutWaitingForRemote proves the nonblocking
118+// startup guarantee: socket publication, local attach and first paint
119+// complete while every remote lane is deliberately blocked, and the monitor
120+// runner starts without consuming any admission.
121+func TestServeStartsMonitorWithoutWaitingForRemote(t *testing.T) {
122+ monitor := &scriptRemoteMonitor{started: make(chan struct{}), stopped: make(chan struct{})}
123+ d, l, _, sends, releasePTY := newMonitorServeHarness(t, monitor)
124+ defer releasePTY()
125+
126+ ctx, cancel := context.WithCancel(context.Background())
127+ defer cancel()
128+ served := make(chan error, 1)
129+ go func() { served <- d.Serve(ctx, l) }()
130+
131+ select {
132+ case <-monitor.started:
133+ case <-time.After(5 * time.Second):
134+ t.Fatal("monitor runner did not start with Serve")
135+ }
136+
137+ // Local attach and first paint flow while remote I/O stays blocked.
138+ awaitFrame(t, sends, wire.MsgWelcome)
139+ awaitFrame(t, sends, wire.MsgOutput)
140+
141+ // The runner is not an attachment: it holds no session and performs no
142+ // admission of its own.
143+ require.Equal(t, 1, sessionCount(d))
144+
145+ cancel()
146+ select {
147+ case err := <-served:
148+ require.NoError(t, err)
149+ case <-time.After(10 * time.Second):
150+ t.Fatal("Serve did not return after context cancel")
151+ }
152+ select {
153+ case <-monitor.stopped:
154+ case <-time.After(5 * time.Second):
155+ t.Fatal("monitor runner did not stop with Serve")
156+ }
157+}
158+
159+// TestServeIdleExitWithMonitorRunning proves monitoring never counts as an
160+// attachment: the last session exit still drives Serve to return, and the
161+// monitor stops with the daemon.
162+func TestServeIdleExitWithMonitorRunning(t *testing.T) {
163+ monitor := &scriptRemoteMonitor{started: make(chan struct{}), stopped: make(chan struct{})}
164+ d, l, _, sends, releasePTY := newMonitorServeHarness(t, monitor)
165+
166+ served := make(chan error, 1)
167+ go func() { served <- d.Serve(context.Background(), l) }()
168+
169+ awaitFrame(t, sends, wire.MsgWelcome)
170+ awaitFrame(t, sends, wire.MsgOutput)
171+
172+ // Child exits -> session removed -> registry empties -> Serve returns,
173+ // even though the monitor runner is still parked in its context.
174+ releasePTY()
175+ select {
176+ case err := <-served:
177+ require.NoError(t, err)
178+ case <-time.After(10 * time.Second):
179+ t.Fatal("Serve did not return after last session exited")
180+ }
181+ select {
182+ case <-monitor.stopped:
183+ case <-time.After(5 * time.Second):
184+ t.Fatal("monitor runner did not stop after idle exit")
185+ }
186+}
187+
188+// TestServeShutdownJoinBoundedForStuckMonitor proves an uncooperative
189+// monitor worker cannot extend daemon shutdown: Serve returns once the
190+// bounded join expires. It uses a real clock so the join budget fires.
191+func TestServeShutdownJoinBoundedForStuckMonitor(t *testing.T) {
192+ p, releasePTY := newBlockingPTY(t)
193+ defer releasePTY()
194+ tr, sends, _ := newConn(t, mustHello(protocol.IntentEphemeral, "", domain.Size{Cols: 80, Rows: 24}))
195+
196+ l := newMockServerListener(t)
197+ connCh := make(chan wire.Transport, 1)
198+ connCh <- tr
199+ closed := make(chan struct{})
200+ var once sync.Once
201+ l.EXPECT().Accept().RunAndReturn(func() (wire.Transport, error) {
202+ select {
203+ case c := <-connCh:
204+ return c, nil
205+ case <-closed:
206+ return nil, io.EOF
207+ }
208+ }).Maybe()
209+ l.EXPECT().Close().RunAndReturn(func() error { once.Do(func() { close(closed) }); return nil }).Maybe()
210+ l.EXPECT().Addr().Return("mock").Maybe()
211+
212+ monitor := &scriptRemoteMonitor{started: make(chan struct{}), stopped: make(chan struct{}), hang: true}
213+ d := New(newFactory(t, p), clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil)),
214+ WithRemoteMonitor(&stubRemoteDirectory{}, monitor.run),
215+ )
216+
217+ ctx, cancel := context.WithCancel(context.Background())
218+ defer cancel()
219+ served := make(chan error, 1)
220+ go func() { served <- d.Serve(ctx, l) }()
221+
222+ select {
223+ case <-monitor.started:
224+ case <-time.After(5 * time.Second):
225+ t.Fatal("monitor runner did not start with Serve")
226+ }
227+ awaitFrame(t, sends, wire.MsgWelcome)
228+ awaitFrame(t, sends, wire.MsgOutput)
229+
230+ start := time.Now()
231+ cancel()
232+ select {
233+ case err := <-served:
234+ require.NoError(t, err)
235+ case <-time.After(15 * time.Second):
236+ t.Fatal("stuck monitor wedged daemon shutdown")
237+ }
238+ t.Logf("shutdown with stuck monitor took %v", time.Since(start))
239+}
M internal/usecase/daemon/remote_picker.go +141 -542
@@ -1,142 +1,20 @@
11 package daemon
22
33 import (
4- "context"
54 "encoding/base64"
6- "errors"
75 "fmt"
86 "math"
9- "sort"
107 "time"
118
129 "github.com/bnema/vev/internal/domain"
10+ "github.com/bnema/vev/internal/ports"
1311 "github.com/bnema/vev/internal/protocol/catalogue"
14- "github.com/bnema/vev/internal/usecase/palette"
1512 "github.com/bnema/vev/internal/usecase/picker"
1613 )
1714
18-type remoteCatalogPresentationEntry struct {
19- entry catalogue.RemoteCatalogCacheEntry
20- status remoteHostStatus
21-}
22-
23-// remotePickerInstance is an immutable ownership capability for one published
24-// picker lifecycle. The model identity prevents delayed registration from
25-// attaching ownership to a replacement within the same client.
26-type remotePickerInstance struct {
27- ac *attachedClient
28- generation uint64
29- model *picker.Model
30-}
31-
32-func (i remotePickerInstance) discoveryInstance() remoteDiscoveryInstance {
33- return remoteDiscoveryInstance{
34- ac: i.ac, kind: remoteDiscoveryPicker, generation: i.generation, picker: i.model,
35- }
36-}
37-
38-type remoteDiscoveryInstance struct {
39- ac *attachedClient
40- kind remoteDiscoveryConsumerKind
41- generation uint64
42- picker *picker.Model
43- palette *palette.Model
44-}
45-
46-const remoteCatalogAttachTTL = 30 * time.Second
47-
48-// remoteCatalogSnapshot copies the cache-derived discovery state before any
49-// overlay sorting begins. No remote-state lock survives this snapshot.
50-func (d *Daemon) remoteCatalogSnapshot() []remoteCatalogPresentationEntry {
51- if d == nil {
52- return nil
53- }
54- now := d.clock.Now()
55- d.remoteCatalog.mu.Lock()
56- for host, entry := range d.remoteCatalog.cache {
57- if d.remoteCatalog.status[host] == remoteHostFresh && remoteCatalogExpired(entry.FetchedAt, now) {
58- d.remoteCatalog.status[host] = remoteHostStale
59- }
60- }
61- entries := make([]remoteCatalogPresentationEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status))
62- seen := make(map[string]struct{}, len(d.remoteCatalog.cache))
63- for host, entry := range d.remoteCatalog.cache {
64- entries = append(entries, remoteCatalogPresentationEntry{
65- entry: cloneRemoteCatalogEntry(entry),
66- status: d.remoteCatalog.status[host],
67- })
68- seen[host] = struct{}{}
69- }
70- for host, status := range d.remoteCatalog.status {
71- if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch && status != remoteHostMalformed) {
72- continue
73- }
74- entries = append(entries, remoteCatalogPresentationEntry{
75- entry: catalogue.RemoteCatalogCacheEntry{Host: host},
76- status: status,
77- })
78- }
79- d.remoteCatalog.mu.Unlock()
80- return entries
81-}
82-
83-func (d *Daemon) remoteHostRanks() map[string]int {
84- ranks := make(map[string]int)
85- if d == nil || d.remoteHostStore == nil {
86- return ranks
87- }
88- pinned, learned, err := d.remoteHostStore.Hosts()
89- if err != nil {
90- d.log.Warn("listing remote hosts for discovery failed", "err", err)
91- return ranks
92- }
93- for _, host := range append(pinned, learned...) {
94- if _, exists := ranks[host]; !exists {
95- ranks[host] = len(ranks)
96- }
97- }
98- return ranks
99-}
100-
101-func remoteCatalogExpired(fetchedAt, now time.Time) bool {
102- return fetchedAt.IsZero() || (!now.Before(fetchedAt) && now.Sub(fetchedAt) >= remoteCatalogAttachTTL)
103-}
104-
105-func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability {
106- switch status {
107- case remoteHostFresh:
108- return picker.RemoteFresh
109- case remoteHostStale, remoteHostUnreachable, remoteHostMalformed:
110- return picker.RemoteStale
111- case remoteHostVersionMismatch:
112- return picker.RemoteVersionMismatch
113- default:
114- return picker.RemoteCached
115- }
116-}
117-
118-func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string {
119- switch status {
120- case remoteHostCached, remoteHostRefreshing:
121- return "checking remote…"
122- case remoteHostStale:
123- if !fetchedAt.IsZero() {
124- return "catalog stale since " + fetchedAt.Format(time.RFC3339)
125- }
126- return "catalog stale"
127- case remoteHostUnreachable:
128- if !fetchedAt.IsZero() {
129- return "stale since " + fetchedAt.Format(time.RFC3339)
130- }
131- return "unreachable"
132- case remoteHostVersionMismatch:
133- return "version mismatch"
134- case remoteHostMalformed:
135- return "catalog malformed"
136- default:
137- return ""
138- }
139-}
15+// directoryInventoryStaleAge is the presentation freshness threshold: past
16+// this age inventory renders stale, but staleness never gates activation.
17+const directoryInventoryStaleAge = 30 * time.Second
14018
14119 func remoteSessionStateStopped(state catalogue.RemoteCatalogSessionState) bool {
14220 return state == catalogue.RemoteCatalogSessionDown
@@ -177,9 +55,17 @@ func remoteCatalogSessionTarget(key domain.RemoteSessionKey, session catalogue.R
17755 return key, target
17856 }
17957
180-func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
58+// directoryInventoryStale reports whether the host's last success is past
59+// the presentation freshness threshold. Staleness renders; it never gates.
60+func directoryInventoryStale(host ports.RemoteHostSnapshot, now time.Time) bool {
61+ if host.LastSuccess.IsZero() {
62+ return host.InventoryKnown
63+ }
64+ return !now.Before(host.LastSuccess.Add(directoryInventoryStaleAge))
65+}
66+
67+func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatalogSession, host ports.RemoteHostSnapshot, now time.Time) picker.SessionView {
18168 key, target := remoteCatalogSessionTarget(key, session)
182- availability := remotePickerAvailability(status)
18369 stopped := remoteSessionStateStopped(session.State)
18470 broken := session.State == catalogue.RemoteCatalogSessionBroken
18571 tabs := catalogue.CatalogTabs(session)
@@ -206,14 +92,14 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal
20692 // must remain possible, while sendRemoteAttachTargetForAttachment will
20793 // fail closed on the invalid target.
20894 remoteTarget := &target
209- reason := remoteReasonForStatus(status)
95+ reason := directorySessionReason(host, session, target)
21096 targetValid := remoteTarget.Validate() == nil
21197 activation := picker.RemoteUnavailable
212- if broken {
213- reason = "session_broken"
214- } else if !targetValid {
215- reason = "identity_changed"
216- } else if status == remoteHostFresh {
98+ if !broken && targetValid && host.Availability != domain.RemoteAvailabilityIncompatible {
99+ // Known inventory is attemptable at any age: freshness is
100+ // presentation information, not attach authority. Exact session,
101+ // lifecycle and tab validation still applies at submit, and the
102+ // destination rejects precisely.
217103 if stopped {
218104 activation = picker.RemoteRestart
219105 } else {
@@ -221,31 +107,14 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal
221107 }
222108 }
223109
224- detail := remotePickerStatusDetail(status, fetchedAt)
225- if detail == "" {
226- switch {
227- case broken:
228- detail = "session broken"
229- case !targetValid:
230- detail = "identity changed"
231- case activation == picker.RemoteRestart:
232- detail = "stopped — Enter to restart"
233- case session.State == catalogue.RemoteCatalogSessionUp:
234- detail = "up"
235- case stopped:
236- detail = "stopped"
237- default:
238- detail = string(session.State)
239- }
240- }
241110 return picker.SessionView{
242111 ID: key.ID(),
243112 Name: key.Display(),
244113 RemoteKey: &key,
245114 RemoteTarget: remoteTarget,
246115 RemoteHost: key.Host,
247- RemoteAvailability: availability,
248- RemoteDetail: detail,
116+ RemoteAvailability: directoryPickerAvailability(host, now),
117+ RemoteDetail: directoryPickerDetail(host, session, reason, activation, now),
249118 RemoteReason: reason,
250119 RemoteActivation: activation,
251120 Tabs: viewTabs,
@@ -255,424 +124,154 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal
255124 }
256125 }
257126
258-func remoteReasonForStatus(status remoteHostStatus) string {
259- switch status {
260- case remoteHostRefreshing:
261- return "refreshing"
262- case remoteHostStale:
263- return "catalog_stale"
264- case remoteHostUnreachable:
265- return "host_unreachable"
266- case remoteHostVersionMismatch:
267- return "version_mismatch"
268- case remoteHostMalformed:
269- return "malformed"
270- case remoteHostCached:
271- return "refreshing"
272- default:
273- return ""
274- }
275-}
276-
277-func remotePickerHostView(host string, status remoteHostStatus) picker.SessionView {
278- return picker.SessionView{
279- ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host))),
280- Name: host,
281- RemoteHost: host,
282- RemoteReason: remoteReasonForStatus(status),
283- RemoteAvailability: remotePickerAvailability(status),
284- RemoteDetail: remotePickerStatusDetail(status, time.Time{}),
285- RemoteActivation: picker.RemoteUnavailable,
286- CannotAcceptMoves: true,
287- }
288-}
289-
290-func sortRemoteCatalog(entries []remoteCatalogPresentationEntry, ranks map[string]int) {
291- for i := range entries {
292- sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {
293- l, r := entries[i].entry.Sessions[left], entries[i].entry.Sessions[right]
294- if l.LastUsedSeq != r.LastUsedSeq {
295- return l.LastUsedSeq > r.LastUsedSeq
296- }
297- return l.Name < r.Name
298- })
127+// directorySessionReason labels a session row for display. It never gates
128+// activation except through broken, invalid, or incompatible states; age and
129+// observation failures render while the row stays attemptable.
130+func directorySessionReason(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession, target domain.RemoteSessionTarget) string {
131+ if session.State == catalogue.RemoteCatalogSessionBroken {
132+ return domain.RemoteReasonSessionBroken
299133 }
300- sort.Slice(entries, func(i, j int) bool {
301- left, right := entries[i].entry.Host, entries[j].entry.Host
302- leftRank, leftKnown := ranks[left]
303- rightRank, rightKnown := ranks[right]
304- if leftKnown != rightKnown {
305- return leftKnown
306- }
307- if leftKnown && leftRank != rightRank {
308- return leftRank < rightRank
309- }
310- return left < right
311- })
312-}
313-
314-func (d *Daemon) remoteDiscoveryOpened(instance remoteDiscoveryInstance) {
315- if d.registerRemoteDiscoveryConsumer(instance) {
316- d.startRemoteDiscoveryRefresh(instance)
134+ if target.Validate() != nil {
135+ return domain.RemoteReasonIdentityChanged
317136 }
318-}
319-
320-// registerRemoteDiscoveryConsumer installs one exact overlay generation as a
321-// refresh owner. Overlay ownership always nests its model lock before the
322-// catalog lock; no path may acquire an overlay lock while retaining catalog.mu.
323-func (d *Daemon) registerRemoteDiscoveryConsumer(instance remoteDiscoveryInstance) bool {
324- if d == nil || instance.ac == nil || instance.ac.overlays == nil {
325- return false
326- }
327- rt := instance.ac.overlays
328- switch instance.kind {
329- case remoteDiscoveryPicker:
330- if instance.picker == nil {
331- return false
332- }
333- rt.pickerMu.Lock()
334- defer rt.pickerMu.Unlock()
335- if rt.pickerGeneration != instance.generation || rt.picker != instance.picker {
336- return false
337- }
338- case remoteDiscoveryPalette:
339- if instance.palette == nil {
340- return false
341- }
342- rt.paletteMu.Lock()
343- defer rt.paletteMu.Unlock()
344- if rt.paletteGeneration != instance.generation || rt.palette != instance.palette {
345- return false
137+ switch host.Availability {
138+ case domain.RemoteAvailabilityReachable:
139+ if host.Checking {
140+ return domain.RemoteReasonRefreshing
346141 }
142+ return ""
143+ case domain.RemoteAvailabilityUnreachable:
144+ return domain.RemoteReasonHostUnreachable
145+ case domain.RemoteAvailabilityIncompatible:
146+ return domain.RemoteReasonVersionMismatch
147+ case domain.RemoteAvailabilityAuthFailed:
148+ return domain.RemoteReasonAuthFailure
149+ case domain.RemoteAvailabilityInvalidResponse:
150+ return domain.RemoteReasonMalformed
347151 default:
348- return false
152+ return domain.RemoteReasonRefreshing
349153 }
350- d.remoteCatalog.mu.Lock()
351- d.remoteCatalog.consumers[instance.ac] |= instance.kind
352- d.remoteCatalog.mu.Unlock()
353- return true
354154 }
355155
356-func (d *Daemon) remoteDiscoveryClosed(instance remoteDiscoveryInstance) {
357- if d == nil || instance.ac == nil || instance.ac.overlays == nil {
358- return
359- }
360- rt := instance.ac.overlays
361- var current bool
362- var unlock func()
363- switch instance.kind {
364- case remoteDiscoveryPicker:
365- if instance.picker == nil {
366- return
367- }
368- rt.pickerMu.Lock()
369- unlock = rt.pickerMu.Unlock
370- current = rt.pickerGeneration == instance.generation && rt.picker == nil
371- case remoteDiscoveryPalette:
372- if instance.palette == nil {
373- return
156+func directoryPickerAvailability(host ports.RemoteHostSnapshot, now time.Time) picker.RemoteAvailability {
157+ switch host.Availability {
158+ case domain.RemoteAvailabilityReachable:
159+ if directoryInventoryStale(host, now) {
160+ return picker.RemoteStale
374161 }
375- rt.paletteMu.Lock()
376- unlock = rt.paletteMu.Unlock
377- current = rt.paletteGeneration == instance.generation && rt.palette == nil
162+ return picker.RemoteFresh
163+ case domain.RemoteAvailabilityIncompatible:
164+ return picker.RemoteVersionMismatch
165+ case domain.RemoteAvailabilityUnknown:
166+ return picker.RemoteCached
378167 default:
379- return
380- }
381- if !current {
382- unlock()
383- return
384- }
385- d.remoteCatalog.mu.Lock()
386- owners := d.remoteCatalog.consumers[instance.ac]
387- if owners&instance.kind == 0 {
388- d.remoteCatalog.mu.Unlock()
389- unlock()
390- return
391- }
392- owners &^= instance.kind
393- if owners == 0 {
394- delete(d.remoteCatalog.consumers, instance.ac)
395- } else {
396- d.remoteCatalog.consumers[instance.ac] = owners
397- }
398- if len(d.remoteCatalog.consumers) != 0 {
399- d.remoteCatalog.mu.Unlock()
400- unlock()
401- return
402- }
403- d.remoteCatalog.refresh++
404- cancel := d.remoteCatalog.cancel
405- d.remoteCatalog.cancel = nil
406- d.remoteCatalog.mu.Unlock()
407- unlock()
408- if cancel != nil {
409- cancel()
410- }
411-}
412-
413-func (d *Daemon) cancelRemoteDiscoveryRefresh() {
414- if d == nil {
415- return
416- }
417- d.remoteCatalog.mu.Lock()
418- d.remoteCatalog.refresh++
419- cancel := d.remoteCatalog.cancel
420- d.remoteCatalog.cancel = nil
421- d.remoteCatalog.mu.Unlock()
422- if cancel != nil {
423- cancel()
424- }
425-}
426-
427-func (d *Daemon) remoteDiscoveryHosts() ([]string, error) {
428- if d == nil || d.remoteHostStore == nil {
429- return nil, nil
430- }
431- pinned, learned, err := d.remoteHostStore.Hosts()
432- if err != nil {
433- return nil, err
434- }
435- hosts := make([]string, 0, len(pinned)+len(learned))
436- seen := make(map[string]struct{}, cap(hosts))
437- add := func(candidates []string) {
438- for _, host := range candidates {
439- if _, exists := seen[host]; exists {
440- continue
441- }
442- seen[host] = struct{}{}
443- hosts = append(hosts, host)
168+ if host.Checking {
169+ return picker.RemoteCached
444170 }
171+ return picker.RemoteStale
445172 }
446- add(pinned)
447- add(learned)
448- return hosts, nil
449173 }
450174
451-// startRemoteDiscoveryRefresh replaces the current refresh generation and
452-// launches one bounded catalog call per known host. Installing the generation
453-// is gated by the exact overlay generation that registered ownership. Host
454-// store and remote I/O happen after every architecture lock is released.
455-func (d *Daemon) startRemoteDiscoveryRefresh(instance remoteDiscoveryInstance) uint64 {
456- if d == nil || instance.ac == nil || instance.ac.overlays == nil {
457- return 0
458- }
459- root := context.Background()
460- if d.serveCtx != nil {
461- root = d.serveCtx
462- }
463- ctx, cancel := context.WithCancel(root)
464-
465- rt := instance.ac.overlays
466- install := func(current bool) (context.CancelFunc, uint64, bool) {
467- d.remoteCatalog.mu.Lock()
468- defer d.remoteCatalog.mu.Unlock()
469- owner := d.remoteCatalog.consumers[instance.ac]&instance.kind != 0
470- if !current || !owner || len(d.remoteCatalog.consumers) == 0 {
471- return nil, 0, false
472- }
473- previous := d.remoteCatalog.cancel
474- d.remoteCatalog.refresh++
475- generation := d.remoteCatalog.refresh
476- d.remoteCatalog.cancel = cancel
477- return previous, generation, true
175+func directoryPickerDetail(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession, reason string, activation picker.RemoteActivation, now time.Time) string {
176+ if host.Checking {
177+ return "checking remote…"
478178 }
479- var previous context.CancelFunc
480- var generation uint64
481- var installed bool
482- switch instance.kind {
483- case remoteDiscoveryPicker:
484- if instance.picker == nil {
485- cancel()
486- return 0
179+ stopped := remoteSessionStateStopped(session.State)
180+ broken := session.State == catalogue.RemoteCatalogSessionBroken
181+ switch host.Availability {
182+ case domain.RemoteAvailabilityReachable:
183+ if !host.LastSuccess.IsZero() && directoryInventoryStale(host, now) {
184+ return "catalog stale since " + host.LastSuccess.Format(time.RFC3339)
487185 }
488- rt.pickerMu.Lock()
489- previous, generation, installed = install(rt.pickerGeneration == instance.generation && rt.picker == instance.picker)
490- rt.pickerMu.Unlock()
491- case remoteDiscoveryPalette:
492- if instance.palette == nil {
493- cancel()
494- return 0
186+ case domain.RemoteAvailabilityUnreachable:
187+ if host.InventoryKnown && !host.LastSuccess.IsZero() {
188+ return "stale since " + host.LastSuccess.Format(time.RFC3339)
495189 }
496- rt.paletteMu.Lock()
497- previous, generation, installed = install(rt.paletteGeneration == instance.generation && rt.palette == instance.palette)
498- rt.paletteMu.Unlock()
190+ return "unreachable"
191+ case domain.RemoteAvailabilityIncompatible:
192+ return "version mismatch"
193+ case domain.RemoteAvailabilityAuthFailed:
194+ return "authentication failed"
195+ case domain.RemoteAvailabilityInvalidResponse:
196+ return "catalog malformed"
499197 default:
500- cancel()
501- return 0
502- }
503- if !installed {
504- cancel()
505- return 0
506- }
507- if previous != nil {
508- previous()
509- }
510- if d.remoteCatalogClient == nil {
511- return generation
512- }
513-
514- hosts, err := d.remoteDiscoveryHosts()
515- if err != nil {
516- d.log.Warn("listing remote hosts for refresh failed", "err", err)
517- return generation
518- }
519- known := make(map[string]struct{}, len(hosts))
520- for _, host := range hosts {
521- known[host] = struct{}{}
522- }
523-
524- d.remoteCatalog.mu.Lock()
525- if d.remoteCatalog.refresh != generation {
526- d.remoteCatalog.mu.Unlock()
527- cancel()
528- return generation
529- }
530- cacheChanged := false
531- for host := range d.remoteCatalog.cache {
532- if _, exists := known[host]; exists {
533- continue
534- }
535- delete(d.remoteCatalog.cache, host)
536- delete(d.remoteCatalog.status, host)
537- delete(d.remoteCatalog.failure, host)
538- cacheChanged = true
539- }
540- for _, host := range hosts {
541- d.remoteCatalog.status[host] = remoteHostRefreshing
542- }
543- d.remoteCatalog.mu.Unlock()
544- if cacheChanged {
545- d.persistRemoteCatalogCache()
198+ return "checking remote…"
546199 }
547- d.refreshRemoteDiscoveryConsumers()
548-
549- for _, host := range hosts {
550- d.sessWg.Go(func() {
551- catalog, listErr := d.remoteCatalogClient.List(ctx, host)
552- d.applyRemoteRefreshResult(generation, host, catalog, listErr)
553- })
200+ switch {
201+ case broken:
202+ return "session broken"
203+ case reason == domain.RemoteReasonIdentityChanged:
204+ return "identity changed"
205+ case activation == picker.RemoteRestart:
206+ return "stopped — Enter to restart"
207+ case session.State == catalogue.RemoteCatalogSessionUp:
208+ return "up"
209+ case stopped:
210+ return "stopped"
211+ default:
212+ return string(session.State)
554213 }
555- return generation
556214 }
557215
558-func containsRemoteDiscoveryHost(hosts []string, target string) bool {
559- for _, host := range hosts {
560- if host == target {
561- return true
562- }
216+// remotePickerCheckingView is the non-actionable placeholder published
217+// while the remote directory has never been initialized: it distinguishes
218+// "still checking remotes" from "no remotes configured". Enter on the
219+// row fails safe through the usual unavailable-target path.
220+func remotePickerCheckingView() picker.SessionView {
221+ return picker.SessionView{
222+ ID: domain.SessionID("remote:checking"),
223+ Name: "checking remotes…",
224+ RemoteActivation: picker.RemoteUnavailable,
225+ CannotAcceptMoves: true,
563226 }
564- return false
565227 }
566228
567-// applyRemoteRefreshResult publishes one host completion only if both its
568-// refresh generation and current host-registry membership remain authoritative.
569-// Its return reports whether durable cache state changed.
570-func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalog catalogue.RemoteCatalog, listErr error) bool {
571- if d == nil {
572- return false
573- }
574- d.remoteCatalog.mu.Lock()
575- current := d.remoteCatalog.refresh == generation
576- d.remoteCatalog.mu.Unlock()
577- if !current {
578- return false
579- }
580-
581- hosts, hostsErr := d.remoteDiscoveryHosts()
582- if hostsErr != nil {
583- d.log.Warn("revalidating remote host refresh failed", "host", host, "err", hostsErr)
584- return false
585- }
586- stillKnown := containsRemoteDiscoveryHost(hosts, host)
587- now := d.clock.Now()
588- if listErr == nil {
589- if err := catalogue.ValidateRemoteCatalog(catalog); err != nil {
590- listErr = err
591- }
229+func remotePickerHostView(host ports.RemoteHostSnapshot, now time.Time) picker.SessionView {
230+ return picker.SessionView{
231+ ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host.Endpoint))),
232+ Name: host.Endpoint,
233+ RemoteHost: host.Endpoint,
234+ RemoteReason: directoryHostReason(host),
235+ RemoteAvailability: directoryPickerAvailability(host, now),
236+ RemoteDetail: directoryHostDetail(host),
237+ RemoteActivation: picker.RemoteUnavailable,
238+ CannotAcceptMoves: true,
592239 }
240+}
593241
594- d.remoteCatalog.mu.Lock()
595- if d.remoteCatalog.refresh != generation {
596- d.remoteCatalog.mu.Unlock()
597- return false
598- }
599- if !stillKnown {
600- _, cacheChanged := d.remoteCatalog.cache[host]
601- delete(d.remoteCatalog.cache, host)
602- delete(d.remoteCatalog.status, host)
603- delete(d.remoteCatalog.failure, host)
604- d.remoteCatalog.mu.Unlock()
605- if cacheChanged {
606- d.persistRemoteCatalogCache()
607- d.refreshRemoteDiscoveryConsumers()
608- }
609- return cacheChanged
610- }
611- if listErr != nil {
612- status := remoteHostMalformed
613- var mismatch *catalogue.RemoteCatalogVersionMismatchError
614- if errors.As(listErr, &mismatch) {
615- status = remoteHostVersionMismatch
616- } else if !errors.Is(listErr, catalogue.ErrInvalidRemoteCatalog) &&
617- !errors.Is(listErr, catalogue.ErrRemoteCatalogTooLarge) &&
618- !errors.Is(listErr, catalogue.ErrRemoteCatalogUnknownState) &&
619- !errors.Is(listErr, catalogue.ErrRemoteCatalogInvalidReason) {
620- status = remoteHostUnreachable
621- }
622- d.remoteCatalog.status[host] = status
623- d.remoteCatalog.failure[host] = now
624- d.remoteCatalog.mu.Unlock()
625- d.refreshRemoteDiscoveryConsumers()
626- return false
242+func directoryHostReason(host ports.RemoteHostSnapshot) string {
243+ switch host.Availability {
244+ case domain.RemoteAvailabilityReachable:
245+ return ""
246+ case domain.RemoteAvailabilityUnreachable:
247+ return domain.RemoteReasonHostUnreachable
248+ case domain.RemoteAvailabilityIncompatible:
249+ return domain.RemoteReasonVersionMismatch
250+ case domain.RemoteAvailabilityAuthFailed:
251+ return domain.RemoteReasonAuthFailure
252+ case domain.RemoteAvailabilityInvalidResponse:
253+ return domain.RemoteReasonMalformed
254+ default:
255+ return domain.RemoteReasonRefreshing
627256 }
628- d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(catalogue.RemoteCatalogCacheEntry{
629- Host: host,
630- FetchedAt: now,
631- Sessions: catalog.Sessions,
632- })
633- d.remoteCatalog.status[host] = remoteHostFresh
634- delete(d.remoteCatalog.failure, host)
635- d.remoteCatalog.mu.Unlock()
636-
637- d.persistRemoteCatalogCache()
638- d.refreshRemoteDiscoveryConsumers()
639- return true
640257 }
641258
642-func (d *Daemon) refreshRemoteDiscoveryConsumers() {
643- if d == nil {
644- return
645- }
646- type consumer struct {
647- ac *attachedClient
648- kind remoteDiscoveryConsumerKind
649- }
650- d.remoteCatalog.mu.Lock()
651- consumers := make([]consumer, 0, len(d.remoteCatalog.consumers))
652- for ac, kind := range d.remoteCatalog.consumers {
653- consumers = append(consumers, consumer{ac: ac, kind: kind})
259+func directoryHostDetail(host ports.RemoteHostSnapshot) string {
260+ if host.Checking {
261+ return "checking remote…"
654262 }
655- d.remoteCatalog.mu.Unlock()
656-
657- for _, consumer := range consumers {
658- ac := consumer.ac
659- if ac == nil || ac.overlays == nil {
660- continue
661- }
662- refreshed := false
663- if consumer.kind&remoteDiscoveryPicker != 0 {
664- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
665- refreshed = true
666- }
667- if consumer.kind&remoteDiscoveryPalette != 0 {
668- d.refreshPalette(ac)
669- refreshed = true
670- }
671- if !refreshed {
672- continue
673- }
674- if entry := ac.currentAttachmentSession(); entry != nil {
675- d.invalidateRender(entry, ac, true, "remote_picker.go")
676- }
263+ switch host.Availability {
264+ case domain.RemoteAvailabilityReachable:
265+ return "no sessions"
266+ case domain.RemoteAvailabilityUnreachable:
267+ return "unreachable"
268+ case domain.RemoteAvailabilityIncompatible:
269+ return "version mismatch"
270+ case domain.RemoteAvailabilityAuthFailed:
271+ return "authentication failed"
272+ case domain.RemoteAvailabilityInvalidResponse:
273+ return "catalog malformed"
274+ default:
275+ return "checking remote…"
677276 }
678277 }
M internal/usecase/daemon/remote_picker_test.go +315 -674
@@ -15,6 +15,7 @@ import (
1515 "github.com/stretchr/testify/require"
1616
1717 "github.com/bnema/vev/internal/domain"
18+ "github.com/bnema/vev/internal/ports"
1819 appports "github.com/bnema/vev/internal/ports"
1920 portsmocks "github.com/bnema/vev/internal/ports/mocks"
2021 "github.com/bnema/vev/internal/protocol"
@@ -57,8 +58,41 @@ func receiveRemotePickerClose(t *testing.T, ch <-chan struct{}, what string) {
5758 require.NoError(t, waitRemotePickerClose(ch, what))
5859 }
5960
60-func newRemotePickerDaemon(store appports.RemoteHostStore) *Daemon {
61- return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil))
61+func newRemotePickerDaemon() *Daemon {
62+ return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)))
63+}
64+
65+// seedRemoteDirectory installs a fully defensive directory publication on the
66+// daemon. Presentation reads it snapshot-only and performs no I/O.
67+func seedRemoteDirectory(t *testing.T, d *Daemon, hosts ...ports.RemoteHostSnapshot) {
68+ t.Helper()
69+ for i := range hosts {
70+ hosts[i].Rank = i
71+ if hosts[i].DisplayOrigin == "" && hosts[i].Endpoint != "" {
72+ hosts[i].DisplayOrigin = domain.RemoteDisplayOrigin(hosts[i].Endpoint)
73+ }
74+ }
75+ d.remoteDirectory = &stubRemoteDirectory{
76+ snapshot: ports.RemoteDirectorySnapshot{Revision: 1, Initialized: true, Hosts: hosts},
77+ }
78+}
79+
80+func reachableDirectoryHost(endpoint string, fetchedAt time.Time, sessions ...catalogue.RemoteCatalogSession) ports.RemoteHostSnapshot {
81+ return ports.RemoteHostSnapshot{
82+ Endpoint: endpoint,
83+ Availability: domain.RemoteAvailabilityReachable,
84+ LastSuccess: fetchedAt,
85+ InventoryKnown: true,
86+ Sessions: sessions,
87+ }
88+}
89+
90+func directorySessionForTest(name string) catalogue.RemoteCatalogSession {
91+ return catalogue.RemoteCatalogSession{
92+ LifecycleID: remoteLifecycleForTest(), Name: name, State: catalogue.RemoteCatalogSessionUp,
93+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-" + name, Index: 0, Name: "main"}},
94+ ActiveTabID: "tab-" + name,
95+ }
6296 }
6397
6498 func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) {
@@ -68,7 +102,8 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T)
68102 LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionDown,
69103 Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
70104 }
71- view := remotePickerView(key, session, remoteHostFresh, time.Unix(100, 0))
105+ host := reachableDirectoryHost("arch", time.Unix(100, 0), session)
106+ view := remotePickerView(key, session, host, time.Unix(100, 0))
72107 require.True(t, view.Stopped)
73108 require.NotNil(t, view.RemoteTarget)
74109 require.True(t, view.RemoteTarget.Stopped)
@@ -82,7 +117,7 @@ func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) {
82117 tabs[0] = catalogue.RemoteCatalogTab{Name: "main"}
83118 view := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, catalogue.RemoteCatalogSession{
84119 LifecycleID: remoteLifecycleForTest(), Name: "work", State: "down", Tabs: tabs,
85- }, remoteHostFresh, time.Unix(100, 0))
120+ }, reachableDirectoryHost("arch", time.Unix(100, 0)), time.Unix(100, 0))
86121
87122 require.NotNil(t, view.RemoteTarget)
88123 require.Equal(t, uint16(math.MaxUint16), view.RemoteTarget.StoppedTab.ExpectedCount)
@@ -90,30 +125,28 @@ func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) {
90125
91126 func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) {
92127 for _, attached := range []bool{false, true} {
93- view := remotePickerView(domain.RemoteSessionKey{Host: "remote", Name: "work"}, catalogue.RemoteCatalogSession{
128+ session := catalogue.RemoteCatalogSession{
94129 LifecycleID: remoteLifecycleForTest(), Name: "work", State: "up", Attached: attached,
95130 Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0}},
96- }, remoteHostFresh, time.Now())
131+ }
132+ view := remotePickerView(domain.RemoteSessionKey{Host: "remote", Name: "work"}, session,
133+ reachableDirectoryHost("remote", time.Now(), session), time.Now())
97134 require.Equal(t, "up", view.RemoteDetail)
98135 }
99136 }
100137
101138 func TestRemotePickerScopesSameLifecycleBytesToEachEndpoint(t *testing.T) {
102- store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}}
103- d := newRemotePickerDaemon(store)
139+ d := newRemotePickerDaemon()
104140 lifecycle := remoteLifecycleForTest()
105141 session := catalogue.RemoteCatalogSession{
106142 LifecycleID: lifecycle, Name: "work", State: "up",
107143 Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
108144 }
109- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{
110- {Host: "remote", FetchedAt: time.Unix(100, 0), Sessions: []catalogue.RemoteCatalogSession{session}},
111- {Host: "vev@remote", FetchedAt: time.Unix(100, 0), Sessions: []catalogue.RemoteCatalogSession{session}},
112- })
113- d.remoteCatalog.mu.Lock()
114- d.remoteCatalog.status["remote"] = remoteHostFresh
115- d.remoteCatalog.status["vev@remote"] = remoteHostFresh
116- d.remoteCatalog.mu.Unlock()
145+ fetchedAt := time.Unix(100, 0)
146+ seedRemoteDirectory(t, d,
147+ reachableDirectoryHost("remote", fetchedAt, session),
148+ reachableDirectoryHost("vev@remote", fetchedAt, session),
149+ )
117150
118151 views, _ := d.pickerViews(nil, nil)
119152 var targets []*domain.RemoteSessionTarget
@@ -127,52 +160,27 @@ func TestRemotePickerScopesSameLifecycleBytesToEachEndpoint(t *testing.T) {
127160 require.Equal(t, "vev@remote", targets[1].Endpoint)
128161 }
129162
130-func TestRemotePickerCatalogExpiresAtAttachTTL(t *testing.T) {
163+// TestRemoteCatalogTargetReadyIgnoresAge proves freshness is presentation,
164+// not attach authority: a stale-but-known target stays valid and the
165+// destination rejects precisely instead of the source declaring it gone.
166+func TestRemoteCatalogTargetReadyIgnoresAge(t *testing.T) {
131167 now := time.Unix(100, 0)
132- clock := &remotePickerClock{now: now}
133- d := newTestDaemon(t, nil, clock)
168+ d := newTestDaemon(t, nil, &remotePickerClock{now: now})
134169 lifecycle := remoteLifecycleForTest()
135- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
136- Host: "arch", FetchedAt: now.Add(-remoteCatalogAttachTTL), Sessions: []catalogue.RemoteCatalogSession{{
137- LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
138- }},
139- }})
140- d.remoteCatalog.mu.Lock()
141- d.remoteCatalog.status["arch"] = remoteHostFresh
142- d.remoteCatalog.mu.Unlock()
143-
144- entries := d.remoteCatalogSnapshot()
145- require.Len(t, entries, 1)
146- require.Equal(t, remoteHostStale, entries[0].status)
170+ session := catalogue.RemoteCatalogSession{
171+ LifecycleID: lifecycle, Name: "work", State: "up",
172+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
173+ ActiveTabID: "tab-1",
174+ }
175+ seedRemoteDirectory(t, d, reachableDirectoryHost("arch", now.Add(-24*time.Hour), session))
176+
147177 target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"}
148- require.False(t, d.remoteCatalogTargetReady(target), "attachment must reject a catalogue exactly at the TTL boundary")
149-}
178+ require.True(t, d.remoteCatalogTargetReady(target), "known inventory stays attemptable at any age")
150179
151-func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testing.T) {
152- now := time.Unix(100, 0)
153- clock := &remotePickerClock{now: now}
154- d := newTestDaemon(t, nil, clock)
155- lifecycle := remoteLifecycleForTest()
156- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
157- Host: "arch", FetchedAt: now, Sessions: []catalogue.RemoteCatalogSession{{
158- LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
159- }},
160- }})
161- d.remoteCatalog.mu.Lock()
162- d.remoteCatalog.status["arch"] = remoteHostFresh
163- d.remoteCatalog.mu.Unlock()
164-
165- model := d.newPickerModel(nil, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
166- target, ok := model.Selected()
167- require.True(t, ok)
168- require.NotNil(t, target.RemoteTarget)
169- require.True(t, d.remoteCatalogTargetReady(*target.RemoteTarget), "fresh picker target must be ready before the attach TTL")
170-
171- clock.Advance(remoteCatalogAttachTTL)
172- require.False(t, d.remoteCatalogTargetReady(*target.RemoteTarget), "handoff must reject a picker target whose catalog reached the attach TTL")
173- d.remoteCatalog.mu.Lock()
174- require.Equal(t, remoteHostStale, d.remoteCatalog.status["arch"])
175- d.remoteCatalog.mu.Unlock()
180+ views, _ := d.pickerViews(nil, nil)
181+ require.Len(t, views, 1)
182+ require.Equal(t, picker.RemoteAttach, views[0].RemoteActivation)
183+ require.Equal(t, picker.RemoteStale, views[0].RemoteAvailability)
176184 }
177185
178186 func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
@@ -216,20 +224,16 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
216224
217225 for _, test := range tests {
218226 t.Run(test.name, func(t *testing.T) {
219- d := newRemotePickerDaemon(nil)
227+ d := newRemotePickerDaemon()
220228 lifecycle := remoteLifecycleForTest()
221- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
222- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
223- LifecycleID: lifecycle,
224- Name: "vive",
225- State: "up",
226- Tabs: test.tabs,
227- ActiveTabID: test.activeTabID,
228- }},
229- }})
230- d.remoteCatalog.mu.Lock()
231- d.remoteCatalog.status["arch"] = remoteHostFresh
232- d.remoteCatalog.mu.Unlock()
229+ session := catalogue.RemoteCatalogSession{
230+ LifecycleID: lifecycle,
231+ Name: "vive",
232+ State: "up",
233+ Tabs: test.tabs,
234+ ActiveTabID: test.activeTabID,
235+ }
236+ seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(10, 0), session))
233237
234238 views, _ := d.pickerViews(nil, nil)
235239 require.Len(t, views, 1)
@@ -247,69 +251,100 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) {
247251 }
248252
249253 func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) {
254+ session := catalogue.RemoteCatalogSession{
255+ LifecycleID: remoteLifecycleForTest(), Name: "work", State: "up",
256+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-work", Index: 0, Name: "main"}}, ActiveTabID: "tab-work",
257+ }
250258 tests := []struct {
251- name string
252- status remoteHostStatus
253- want picker.RemoteAvailability
259+ name string
260+ host ports.RemoteHostSnapshot
261+ want picker.RemoteAvailability
262+ wantActivate bool
254263 }{
255- {name: "cached", status: remoteHostCached, want: picker.RemoteCached},
256- {name: "unreachable", status: remoteHostUnreachable, want: picker.RemoteStale},
257- {name: "version mismatch", status: remoteHostVersionMismatch, want: picker.RemoteVersionMismatch},
264+ {
265+ name: "unknown renders cached and stays attemptable",
266+ host: ports.RemoteHostSnapshot{
267+ Endpoint: "arch", Availability: domain.RemoteAvailabilityUnknown,
268+ Checking: true, InventoryKnown: true, Sessions: []catalogue.RemoteCatalogSession{session},
269+ },
270+ want: picker.RemoteCached, wantActivate: true,
271+ },
272+ {
273+ name: "unreachable with inventory renders stale and stays attemptable",
274+ host: ports.RemoteHostSnapshot{
275+ Endpoint: "arch", Availability: domain.RemoteAvailabilityUnreachable,
276+ LastSuccess: time.Unix(10, 0), InventoryKnown: true,
277+ Sessions: []catalogue.RemoteCatalogSession{session},
278+ },
279+ want: picker.RemoteStale, wantActivate: true,
280+ },
281+ {
282+ name: "version mismatch gates activation",
283+ host: ports.RemoteHostSnapshot{
284+ Endpoint: "arch", Availability: domain.RemoteAvailabilityIncompatible,
285+ InventoryKnown: true, Sessions: []catalogue.RemoteCatalogSession{session},
286+ },
287+ want: picker.RemoteVersionMismatch, wantActivate: false,
288+ },
258289 }
259290
260291 for _, test := range tests {
261292 t.Run(test.name, func(t *testing.T) {
262- d := newRemotePickerDaemon(nil)
263- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
264- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}},
265- }})
266- d.remoteCatalog.mu.Lock()
267- d.remoteCatalog.status["arch"] = test.status
268- d.remoteCatalog.mu.Unlock()
293+ d := newRemotePickerDaemon()
294+ seedRemoteDirectory(t, d, test.host)
269295
270296 views, _ := d.pickerViews(nil, nil)
271297
272298 require.Len(t, views, 1)
273299 require.Equal(t, test.want, views[0].RemoteAvailability)
300+ if test.wantActivate {
301+ require.NotEqual(t, picker.RemoteUnavailable, views[0].RemoteActivation)
302+ } else {
303+ require.Equal(t, picker.RemoteUnavailable, views[0].RemoteActivation)
304+ }
274305 })
275306 }
276307 }
277308
278309 func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) {
279- d := newRemotePickerDaemon(nil)
280310 fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC)
281- failedAt := fetchedAt.Add(6 * time.Hour)
282- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
283- Host: "arch", FetchedAt: fetchedAt, Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}},
284- }})
285- d.remoteCatalog.mu.Lock()
286- d.remoteCatalog.status["arch"] = remoteHostUnreachable
287- d.remoteCatalog.failure["arch"] = failedAt
288- d.remoteCatalog.mu.Unlock()
311+ session := catalogue.RemoteCatalogSession{Name: "work", State: "up"}
312+ d := newRemotePickerDaemon()
313+ seedRemoteDirectory(t, d, ports.RemoteHostSnapshot{
314+ Endpoint: "arch", Availability: domain.RemoteAvailabilityUnreachable,
315+ LastSuccess: fetchedAt, InventoryKnown: true,
316+ Sessions: []catalogue.RemoteCatalogSession{session},
317+ })
289318
290319 views, _ := d.pickerViews(nil, nil)
291320
292321 require.Len(t, views, 1)
293322 require.Equal(t, "stale since "+fetchedAt.Format(time.RFC3339), views[0].RemoteDetail)
294- require.NotContains(t, views[0].RemoteDetail, failedAt.Format(time.RFC3339), "failure time remains runtime-only and is not the freshness authority")
295323 }
296324
297-func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) {
325+func TestRemotePickerNoSessionHostFailuresRemainVisible(t *testing.T) {
298326 for _, test := range []struct {
299327 name string
300- status remoteHostStatus
328+ host ports.RemoteHostSnapshot
301329 want picker.RemoteAvailability
302330 wantDetail string
303331 }{
304- {name: "unreachable", status: remoteHostUnreachable, want: picker.RemoteStale, wantDetail: "unreachable"},
305- {name: "version mismatch", status: remoteHostVersionMismatch, want: picker.RemoteVersionMismatch, wantDetail: "version mismatch"},
332+ {
333+ name: "unreachable",
334+ host: ports.RemoteHostSnapshot{Endpoint: "arch", Availability: domain.RemoteAvailabilityUnreachable},
335+ want: picker.RemoteStale,
336+ wantDetail: "unreachable",
337+ },
338+ {
339+ name: "version mismatch",
340+ host: ports.RemoteHostSnapshot{Endpoint: "arch", Availability: domain.RemoteAvailabilityIncompatible},
341+ want: picker.RemoteVersionMismatch,
342+ wantDetail: "version mismatch",
343+ },
306344 } {
307345 t.Run(test.name, func(t *testing.T) {
308- d := newRemotePickerDaemon(nil)
309- d.remoteCatalog.mu.Lock()
310- d.remoteCatalog.status["arch"] = test.status
311- d.remoteCatalog.failure["arch"] = time.Unix(50, 0)
312- d.remoteCatalog.mu.Unlock()
346+ d := newRemotePickerDaemon()
347+ seedRemoteDirectory(t, d, test.host)
313348
314349 views, _ := d.pickerViews(nil, nil)
315350
@@ -324,102 +359,90 @@ func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) {
324359 }
325360 }
326361
327-type remoteRefreshHostStore struct {
328- mu sync.Mutex
329- hosts []string
330-}
331-
332-func (s *remoteRefreshHostStore) Hosts() ([]string, []string, error) {
333- s.mu.Lock()
334- defer s.mu.Unlock()
335- return append([]string(nil), s.hosts...), nil, nil
362+// TestRemotePickerStaleAndCheckingInventoryRemainsAttemptable is the
363+// presentation half of the P1.1 regression: age and in-flight observation
364+// render while rows stay attemptable; only broken/invalid/incompatible
365+// states gate activation.
366+func TestRemotePickerStaleAndCheckingInventoryRemainsAttemptable(t *testing.T) {
367+ lifecycle := remoteLifecycleForTest()
368+ session := catalogue.RemoteCatalogSession{
369+ LifecycleID: lifecycle, Name: "work", State: "up",
370+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
371+ ActiveTabID: "tab-1",
372+ }
373+ now := time.Unix(1_000, 0)
374+ for _, test := range []struct {
375+ name string
376+ host ports.RemoteHostSnapshot
377+ }{
378+ {name: "stale age", host: reachableDirectoryHost("arch", now.Add(-time.Hour), session)},
379+ {name: "checking", host: func() ports.RemoteHostSnapshot {
380+ host := reachableDirectoryHost("arch", now, session)
381+ host.Checking = true
382+ return host
383+ }()},
384+ } {
385+ t.Run(test.name, func(t *testing.T) {
386+ view := remotePickerView(
387+ domain.RemoteSessionKey{Host: "arch", Name: "work"},
388+ session, test.host, now,
389+ )
390+ require.Equal(t, picker.RemoteAttach, view.RemoteActivation)
391+ require.NotNil(t, view.RemoteTarget)
392+ require.NoError(t, view.RemoteTarget.Validate())
393+ })
394+ }
336395 }
337396
338-func (s *remoteRefreshHostStore) set(hosts ...string) {
339- s.mu.Lock()
340- s.hosts = append([]string(nil), hosts...)
341- s.mu.Unlock()
342-}
397+// TestRemotePickerOverlayCausesNoDirectoryIO proves presentation performs no
398+// registry, cache, or observation I/O: opening, rebuilding, and closing the
399+// picker only re-reads the latest snapshot.
400+func TestRemotePickerOverlayCausesNoDirectoryIO(t *testing.T) {
401+ d := newRemotePickerDaemon()
402+ directory := &countingRemoteDirectory{
403+ snapshot: ports.RemoteDirectorySnapshot{Initialized: true},
404+ }
405+ d.remoteDirectory = directory
406+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
343407
344-func (*remoteRefreshHostStore) AddPinned(string) error { return nil }
345-func (*remoteRefreshHostStore) RemovePinned(string) error { return nil }
346-func (*remoteRefreshHostStore) Remember(string) error { return nil }
347-func (*remoteRefreshHostStore) Forget(string) error { return nil }
348-func (*remoteRefreshHostStore) Remove(string) (bool, error) {
349- return false, nil
350-}
408+ model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
409+ d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
410+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
411+ d.closePicker(ac)
351412
352-type remoteRefreshRequest struct {
353- ctx context.Context
354- host string
355- result chan remoteRefreshResult
413+ require.Zero(t, directory.reconciles, "overlay lifecycle must never request reconciliation")
414+ require.GreaterOrEqual(t, directory.snapshots, 1, "presentation renders from snapshots")
356415 }
357416
358-type remoteRefreshResult struct {
359- catalog catalogue.RemoteCatalog
360- err error
417+type countingRemoteDirectory struct {
418+ mu sync.Mutex
419+ snapshot ports.RemoteDirectorySnapshot
420+ snapshots int
421+ reconciles int
422+ registry int
361423 }
362424
363-type channelRemoteCatalog struct {
364- d *Daemon
365- requests chan remoteRefreshRequest
366- probeMu sync.Mutex
367- probes bool
368- violations []string
425+func (s *countingRemoteDirectory) Snapshot() ports.RemoteDirectorySnapshot {
426+ s.mu.Lock()
427+ defer s.mu.Unlock()
428+ s.snapshots++
429+ return s.snapshot.Clone()
369430 }
370431
371-func (c *channelRemoteCatalog) List(ctx context.Context, host string) (catalogue.RemoteCatalog, error) {
372- c.probeMu.Lock()
373- if c.probes && !c.d.mu.TryLock() {
374- c.violations = append(c.violations, "daemon lock held during remote catalog I/O")
375- } else if c.probes {
376- c.d.mu.Unlock()
377- }
378- if c.probes && !c.d.remoteCatalog.mu.TryLock() {
379- c.violations = append(c.violations, "remote catalog lock held during remote catalog I/O")
380- } else if c.probes {
381- c.d.remoteCatalog.mu.Unlock()
382- }
383- c.probeMu.Unlock()
384- request := remoteRefreshRequest{ctx: ctx, host: host, result: make(chan remoteRefreshResult, 1)}
385- c.requests <- request
386- select {
387- case result := <-request.result:
388- return result.catalog, result.err
389- case <-ctx.Done():
390- return catalogue.RemoteCatalog{}, ctx.Err()
391- }
432+func (s *countingRemoteDirectory) Subscribe() ports.RemoteDirectorySubscription {
433+ return &stubDirectorySubscription{changed: make(chan struct{}, 1)}
392434 }
393435
394-type recordingRemoteCache struct {
395- d *Daemon
396- stores chan []catalogue.RemoteCatalogCacheEntry
397- probeMu sync.Mutex
398- probes bool
399- violations []string
436+func (s *countingRemoteDirectory) RequestReconcile(endpoint string) {
437+ s.mu.Lock()
438+ defer s.mu.Unlock()
439+ s.reconciles++
400440 }
401441
402-func (*recordingRemoteCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { return nil, nil }
403-
404-func (c *recordingRemoteCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error {
405- c.probeMu.Lock()
406- if c.probes && !c.d.mu.TryLock() {
407- c.violations = append(c.violations, "daemon lock held during remote cache write")
408- } else if c.probes {
409- c.d.mu.Unlock()
410- }
411- if c.probes && !c.d.remoteCatalog.mu.TryLock() {
412- c.violations = append(c.violations, "remote catalog lock held during remote cache write")
413- } else if c.probes {
414- c.d.remoteCatalog.mu.Unlock()
415- }
416- c.probeMu.Unlock()
417- copyEntries := make([]catalogue.RemoteCatalogCacheEntry, len(entries))
418- for i, entry := range entries {
419- copyEntries[i] = cloneRemoteCatalogEntry(entry)
420- }
421- c.stores <- copyEntries
422- return nil
442+func (s *countingRemoteDirectory) RegistryChanged() {
443+ s.mu.Lock()
444+ defer s.mu.Unlock()
445+ s.registry++
423446 }
424447
425448 type fixedRemoteRefreshClock struct{ now time.Time }
@@ -444,20 +467,6 @@ func (c *remotePickerClock) Advance(d time.Duration) {
444467 c.mu.Unlock()
445468 }
446469
447-func requireNoRemoteLockViolations(t *testing.T, catalog *channelRemoteCatalog, cache *recordingRemoteCache) {
448- t.Helper()
449- if catalog != nil {
450- catalog.probeMu.Lock()
451- require.Empty(t, catalog.violations)
452- catalog.probeMu.Unlock()
453- }
454- if cache != nil {
455- cache.probeMu.Lock()
456- require.Empty(t, cache.violations)
457- cache.probeMu.Unlock()
458- }
459-}
460-
461470 func remoteCatalogForTest(sessions ...catalogue.RemoteCatalogSession) catalogue.RemoteCatalog {
462471 return catalogue.RemoteCatalog{
463472 ProtocolVersion: protocol.Version,
@@ -466,213 +475,9 @@ func remoteCatalogForTest(sessions ...catalogue.RemoteCatalogSession) catalogue.
466475 }
467476 }
468477
469-func newRemoteRefreshDaemon(t *testing.T, hosts *remoteRefreshHostStore, now time.Time) (*Daemon, *channelRemoteCatalog, *recordingRemoteCache) {
478+func seedPickerDirectoryHost(t *testing.T, d *Daemon, endpoint string, fetchedAt time.Time, sessions ...catalogue.RemoteCatalogSession) {
470479 t.Helper()
471- catalog := &channelRemoteCatalog{requests: make(chan remoteRefreshRequest, 8)}
472- cache := &recordingRemoteCache{stores: make(chan []catalogue.RemoteCatalogCacheEntry, 8)}
473- d := New(nil, fixedRemoteRefreshClock{now: now}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(hosts, catalog, cache))
474- catalog.d, cache.d = d, d
475- return d, catalog, cache
476-}
477-
478-func TestRemoteRefreshStartsPerHostAndCancelsSupersededGeneration(t *testing.T) {
479- hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
480- d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(100, 0))
481- ac := &attachedClient{}
482- ac.initOverlays()
483- model := picker.New(nil, picker.SelectionConfig{})
484- ac.overlays.pickerMu.Lock()
485- ac.overlays.picker = model
486- ac.overlays.pickerGeneration++
487- instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
488- ac.overlays.pickerMu.Unlock()
489- require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
490- lifecycle := remoteLifecycleForTest()
491- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
492- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{
493- LifecycleID: lifecycle, Name: "cached", State: "up",
494- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
495- }},
496- }})
497-
498- firstGeneration := d.startRemoteDiscoveryRefresh(instance.discoveryInstance())
499- first := map[string]remoteRefreshRequest{}
500- for range 2 {
501- request := receiveRemotePicker(t, catalog.requests, "catalog request")
502- first[request.host] = request
503- }
504- require.Contains(t, first, "arch")
505- require.Contains(t, first, "mule")
506- requireNoRemoteLockViolations(t, catalog, nil)
507- d.remoteCatalog.mu.Lock()
508- require.Equal(t, []catalogue.RemoteCatalogSession{{
509- LifecycleID: lifecycle, Name: "cached", State: "up",
510- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}},
511- }}, d.remoteCatalog.cache["arch"].Sessions, "cached rows publish before any remote completion")
512- require.Equal(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
513- d.remoteCatalog.mu.Unlock()
514- ac.overlays.pickerMu.Lock()
515- refreshedModel := ac.overlays.picker
516- ac.overlays.pickerMu.Unlock()
517- _, selectable := refreshedModel.Selected()
518- require.False(t, selectable, "refreshing status must gate picker activation")
519-
520- secondGeneration := d.startRemoteDiscoveryRefresh(instance.discoveryInstance())
521- require.Greater(t, secondGeneration, firstGeneration)
522- for _, request := range first {
523- receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
524- }
525- for range 2 {
526- request := receiveRemotePicker(t, catalog.requests, "catalog request")
527- request.result <- remoteRefreshResult{catalog: remoteCatalogForTest()}
528- }
529- for range 2 {
530- receiveRemotePicker(t, cache.stores, "remote cache store")
531- }
532- requireNoRemoteLockViolations(t, nil, cache)
533-
534- d.remoteCatalog.mu.Lock()
535- require.Equal(t, secondGeneration, d.remoteCatalog.refresh)
536- require.Equal(t, remoteHostFresh, d.remoteCatalog.status["arch"])
537- require.Equal(t, remoteHostFresh, d.remoteCatalog.status["mule"])
538- d.remoteCatalog.mu.Unlock()
539-}
540-
541-func TestRemoteRefreshLockProbesSerialized(t *testing.T) {
542- hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
543- d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(125, 0))
544- catalog.probes, cache.probes = true, true
545- ac := &attachedClient{}
546- ac.initOverlays()
547- model := picker.New(nil, picker.SelectionConfig{})
548- ac.overlays.pickerMu.Lock()
549- ac.overlays.picker = model
550- ac.overlays.pickerGeneration++
551- instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
552- ac.overlays.pickerMu.Unlock()
553- require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
554-
555- d.startRemoteDiscoveryRefresh(instance.discoveryInstance())
556- request := receiveRemotePicker(t, catalog.requests, "catalog request")
557- request.result <- remoteRefreshResult{catalog: remoteCatalogForTest()}
558- receiveRemotePicker(t, cache.stores, "remote cache store")
559- requireNoRemoteLockViolations(t, catalog, cache)
560-}
561-
562-func TestRemoteRefreshWithoutCatalogClientDoesNotMarkHostsRefreshing(t *testing.T) {
563- hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
564- d := newRemotePickerDaemon(hosts)
565- ac := &attachedClient{}
566- ac.initOverlays()
567- model := picker.New(nil, picker.SelectionConfig{})
568- ac.overlays.pickerMu.Lock()
569- ac.overlays.picker = model
570- ac.overlays.pickerGeneration++
571- instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
572- ac.overlays.pickerMu.Unlock()
573- require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
574-
575- require.NotZero(t, d.startRemoteDiscoveryRefresh(instance.discoveryInstance()))
576- d.remoteCatalog.mu.Lock()
577- require.NotEqual(t, remoteHostRefreshing, d.remoteCatalog.status["arch"])
578- d.remoteCatalog.mu.Unlock()
579-}
580-
581-func TestRemoteRefreshResultPreservesFailuresAndEvictsSuccessfulOmissions(t *testing.T) {
582- now := time.Unix(200, 0)
583- hosts := &remoteRefreshHostStore{hosts: []string{"arch", "mule"}}
584- d, _, cache := newRemoteRefreshDaemon(t, hosts, now)
585- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{
586- {Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "old", State: "up"}}},
587- {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "stale", State: "up"}}},
588- })
589- d.remoteCatalog.mu.Lock()
590- d.remoteCatalog.refresh = 7
591- d.remoteCatalog.mu.Unlock()
592-
593- require.False(t, d.applyRemoteRefreshResult(7, "mule", catalogue.RemoteCatalog{}, errors.New("offline")))
594- d.remoteCatalog.mu.Lock()
595- require.Equal(t, []catalogue.RemoteCatalogSession{{Name: "stale", State: "up"}}, d.remoteCatalog.cache["mule"].Sessions)
596- require.Equal(t, time.Unix(20, 0), d.remoteCatalog.cache["mule"].FetchedAt)
597- require.Equal(t, remoteHostUnreachable, d.remoteCatalog.status["mule"])
598- d.remoteCatalog.mu.Unlock()
599-
600- require.True(t, d.applyRemoteRefreshResult(7, "arch", remoteCatalogForTest(), nil))
601- stored := receiveRemotePicker(t, cache.stores, "remote cache store")
602- require.Equal(t, []catalogue.RemoteCatalogCacheEntry{
603- {Host: "arch", FetchedAt: now, Sessions: []catalogue.RemoteCatalogSession{}},
604- {Host: "mule", FetchedAt: time.Unix(20, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "stale", State: "up"}}},
605- }, stored, "every successful write persists the newest full cache atomically")
606- requireNoRemoteLockViolations(t, nil, cache)
607- d.remoteCatalog.mu.Lock()
608- require.Empty(t, d.remoteCatalog.cache["arch"].Sessions, "a successful omission authoritatively evicts old rows")
609- require.Equal(t, remoteHostFresh, d.remoteCatalog.status["arch"])
610- d.remoteCatalog.mu.Unlock()
611-}
612-
613-func TestRemoteRefreshRejectsRemovedHostAndLateGeneration(t *testing.T) {
614- hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
615- d, _, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(300, 0))
616- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
617- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "old", State: "up"}},
618- }})
619- d.remoteCatalog.mu.Lock()
620- d.remoteCatalog.refresh = 9
621- d.remoteCatalog.mu.Unlock()
622-
623- require.False(t, d.applyRemoteRefreshResult(8, "arch", remoteCatalogForTest(catalogue.RemoteCatalogSession{
624- LifecycleID: remoteLifecycleForTest(), Name: "late", State: catalogue.RemoteCatalogSessionUp,
625- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
626- }), nil))
627- hosts.set()
628- require.True(t, d.applyRemoteRefreshResult(9, "arch", remoteCatalogForTest(catalogue.RemoteCatalogSession{
629- LifecycleID: remoteLifecycleForTest(), Name: "removed", State: catalogue.RemoteCatalogSessionUp,
630- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
631- }), nil))
632- require.Empty(t, receiveRemotePicker(t, cache.stores, "remote cache store"))
633- requireNoRemoteLockViolations(t, nil, cache)
634- d.remoteCatalog.mu.Lock()
635- require.NotContains(t, d.remoteCatalog.cache, "arch")
636- require.NotContains(t, d.remoteCatalog.status, "arch")
637- d.remoteCatalog.mu.Unlock()
638-}
639-
640-func TestRemoteRefreshValidationSentinelsRemainMalformed(t *testing.T) {
641- lifecycle := remoteLifecycleForTest()
642- for _, test := range []struct {
643- name string
644- session catalogue.RemoteCatalogSession
645- }{
646- {name: "unknown state", session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "unknown"}},
647- {name: "invalid reason", session: catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: "work", State: "up", Reason: "bogus"}},
648- } {
649- t.Run(test.name, func(t *testing.T) {
650- hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
651- d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))
652- d.remoteCatalog.refresh = 1
653- catalog := catalogue.RemoteCatalog{ProtocolVersion: protocol.Version, SchemaVersion: catalogue.RemoteCatalogSchemaVersion, Sessions: []catalogue.RemoteCatalogSession{test.session}}
654- require.False(t, d.applyRemoteRefreshResult(1, "arch", catalog, nil))
655- d.remoteCatalog.mu.Lock()
656- require.Equal(t, remoteHostMalformed, d.remoteCatalog.status["arch"])
657- d.remoteCatalog.mu.Unlock()
658- })
659- }
660-}
661-
662-func TestRemoteRefreshVersionMismatchPreservesStaleRows(t *testing.T) {
663- hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
664- d, _, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(400, 0))
665- old := catalogue.RemoteCatalogCacheEntry{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}}}
666- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{old})
667- d.remoteCatalog.mu.Lock()
668- d.remoteCatalog.refresh = 3
669- d.remoteCatalog.mu.Unlock()
670-
671- require.False(t, d.applyRemoteRefreshResult(3, "arch", catalogue.RemoteCatalog{}, &catalogue.RemoteCatalogVersionMismatchError{Got: 19, Want: protocol.Version}))
672- d.remoteCatalog.mu.Lock()
673- require.Equal(t, old, d.remoteCatalog.cache["arch"])
674- require.Equal(t, remoteHostVersionMismatch, d.remoteCatalog.status["arch"])
675- d.remoteCatalog.mu.Unlock()
480+ seedRemoteDirectory(t, d, reachableDirectoryHost(endpoint, fetchedAt, sessions...))
676481 }
677482
678483 func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
@@ -681,13 +486,8 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) {
681486 LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
682487 Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
683488 }
684- d := newRemotePickerDaemon(nil)
685- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
686- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{remoteSession},
687- }})
688- d.remoteCatalog.mu.Lock()
689- d.remoteCatalog.status["arch"] = remoteHostFresh
690- d.remoteCatalog.mu.Unlock()
489+ d := newRemotePickerDaemon()
490+ seedPickerDirectoryHost(t, d, "arch", time.Unix(10, 0), remoteSession)
691491 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
692492 token := sess.captureAttachmentCapability(ac, ac.transport())
693493 effect, admitted := ac.beginAttachmentEffect(token)
@@ -720,7 +520,8 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
720520 },
721521 }
722522 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
723- remoteView := remotePickerView(key, remoteSession, remoteHostFresh, time.Unix(10, 0))
523+ host := reachableDirectoryHost("arch", time.Unix(10, 0), remoteSession)
524+ remoteView := remotePickerView(key, remoteSession, host, time.Unix(10, 0))
724525 model := picker.New([]picker.SessionView{
725526 {ID: "local", Name: "local", Tabs: []picker.TabEntry{{TabID: "local-tab", Name: "local"}}},
726527 remoteView,
@@ -738,11 +539,8 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) {
738539 require.True(t, selected.RemoteTarget.Stopped)
739540 require.Equal(t, domain.NewStableTabSelector("tab-b"), selected.RemoteTarget.StoppedTab)
740541
741- local := newRemotePickerDaemon(nil)
742- local.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{remoteSession}}})
743- local.remoteCatalog.mu.Lock()
744- local.remoteCatalog.status["arch"] = remoteHostFresh
745- local.remoteCatalog.mu.Unlock()
542+ local := newRemotePickerDaemon()
543+ seedPickerDirectoryHost(t, local, "arch", time.Unix(10, 0), remoteSession)
746544 localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local")
747545 token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport())
748546 effect, admitted := localAttachment.beginAttachmentEffect(token)
@@ -787,7 +585,8 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing
787585 Tabs: []catalogue.RemoteCatalogTab{},
788586 }
789587 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
790- remoteView := remotePickerView(key, remoteSession, remoteHostFresh, time.Unix(10, 0))
588+ host := reachableDirectoryHost("arch", time.Unix(10, 0), remoteSession)
589+ remoteView := remotePickerView(key, remoteSession, host, time.Unix(10, 0))
791590 require.Equal(t, picker.RemoteRestart, remoteView.RemoteActivation)
792591
793592 model := picker.New([]picker.SessionView{
@@ -807,13 +606,8 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing
807606 require.True(t, selected.RemoteTarget.Stopped)
808607 require.Equal(t, domain.TabSelector{}, selected.RemoteTarget.StoppedTab)
809608
810- local := newRemotePickerDaemon(nil)
811- local.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
812- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{remoteSession},
813- }})
814- local.remoteCatalog.mu.Lock()
815- local.remoteCatalog.status["arch"] = remoteHostFresh
816- local.remoteCatalog.mu.Unlock()
609+ local := newRemotePickerDaemon()
610+ seedPickerDirectoryHost(t, local, "arch", time.Unix(10, 0), remoteSession)
817611 localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local")
818612 token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport())
819613 effect, admitted := localAttachment.beginAttachmentEffect(token)
@@ -853,7 +647,7 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) {
853647
854648 for _, tt := range tests {
855649 t.Run(tt.name, func(t *testing.T) {
856- d := newRemotePickerDaemon(nil)
650+ d := newRemotePickerDaemon()
857651 sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local")
858652 if tt.action == protocol.NavigationOpenHomePicker {
859653 ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker
@@ -883,13 +677,8 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) {
883677 LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp,
884678 Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}},
885679 }
886- d := newRemotePickerDaemon(nil)
887- d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{
888- Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{remoteSession},
889- }})
890- d.remoteCatalog.mu.Lock()
891- d.remoteCatalog.status["arch"] = remoteHostFresh
892- d.remoteCatalog.mu.Unlock()
680+ d := newRemotePickerDaemon()
681+ seedPickerDirectoryHost(t, d, "arch", time.Unix(10, 0), remoteSession)
893682 cause := errors.New("remote attach send failed")
894683 tr := portsmocks.NewMockServerConnection(t)
895684 tr.EXPECT().SendServer(mock.Anything).Return(cause)
@@ -943,49 +732,6 @@ func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID, t
943732 return sess, ac, sends
944733 }
945734
946-func TestRemoteRefreshUpdatesAllOpenPickersPreservingSelection(t *testing.T) {
947- hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
948- d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(450, 0))
949- firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first")
950- _, second, _ := addRemoteRefreshPickerOwner(t, d, "second")
951- before := make(map[*attachedClient]*picker.Model)
952-
953- for _, owner := range []*attachedClient{first, second} {
954- owner.overlays.pickerMu.Lock()
955- owner.overlays.picker = d.newPickerModel(firstSession, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{Session: "second", TabID: "second-tab"})
956- owner.overlays.pickerIntent = pickerNavigate
957- owner.overlays.pickerGeneration++
958- instance := remotePickerInstance{ac: owner, generation: owner.overlays.pickerGeneration, model: owner.overlays.picker}
959- before[owner] = owner.overlays.picker
960- owner.overlays.pickerMu.Unlock()
961- require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
962- }
963- d.remoteCatalog.mu.Lock()
964- d.remoteCatalog.refresh = 4
965- d.remoteCatalog.mu.Unlock()
966-
967- require.True(t, d.applyRemoteRefreshResult(4, "arch", catalogue.RemoteCatalog{
968- ProtocolVersion: protocol.Version,
969- SchemaVersion: catalogue.RemoteCatalogSchemaVersion,
970- Sessions: []catalogue.RemoteCatalogSession{{
971- LifecycleID: remoteLifecycleForTest(), Name: "work", State: "up",
972- Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}, {ID: "tab-2", Index: 1}},
973- }},
974- }, nil))
975- receiveRemotePicker(t, cache.stores, "remote cache store")
976- requireNoRemoteLockViolations(t, catalog, cache)
977-
978- for _, owner := range []*attachedClient{first, second} {
979- owner.overlays.pickerMu.Lock()
980- updated := owner.overlays.picker
981- selected, ok := updated.Selected()
982- owner.overlays.pickerMu.Unlock()
983- require.Same(t, before[owner], updated, "refresh updates rows in place so attachment-local editor state remains owned")
984- require.True(t, ok)
985- require.Equal(t, domain.SessionID("second"), selected.Session)
986- }
987-}
988-
989735 func TestRemotePickerTeardownLifecycle(t *testing.T) {
990736 for _, test := range []struct {
991737 name string
@@ -998,7 +744,7 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) {
998744 {name: "send error removes non-resumable attachment", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }},
999745 } {
1000746 t.Run(test.name, func(t *testing.T) {
1001- d := newRemotePickerDaemon(nil)
747+ d := newRemotePickerDaemon()
1002748 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
1003749 ac.resumeCapable = test.resumeCapable
1004750 model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
@@ -1009,19 +755,14 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) {
1009755
1010756 test.teardown(d, sess, ac)
1011757
1012- d.remoteCatalog.mu.Lock()
1013- _, registered := d.remoteCatalog.consumers[ac]
1014- d.remoteCatalog.mu.Unlock()
1015758 ac.overlays.pickerMu.Lock()
1016759 current, currentGeneration := ac.overlays.picker, ac.overlays.pickerGeneration
1017760 ac.overlays.pickerMu.Unlock()
1018761 if !test.resumeCapable {
1019- require.False(t, registered)
1020762 require.Nil(t, current)
1021763 return
1022764 }
1023765 require.True(t, ac.parked)
1024- require.True(t, registered)
1025766 require.Same(t, model, current)
1026767 require.Equal(t, generation, currentGeneration)
1027768 d.mu.Lock()
@@ -1033,37 +774,102 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) {
1033774 }
1034775 }
1035776
1036-func TestParkedOverlayExpiryClosesCapturedDiscoveryGenerations(t *testing.T) {
1037- clock := &signalClock{timers: make(chan *signalTimer, 1)}
1038- hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
1039- d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(475, 0))
1040- d.clock = clock
777+// TestPickerShowsCheckingRowUntilDirectoryInitializes proves the picker
778+// distinguishes "still checking remotes" from "no remotes": an
779+// uninitialized directory publishes a non-actionable placeholder row, and
780+// the first publication (even empty) retires it.
781+func TestPickerShowsCheckingRowUntilDirectoryInitializes(t *testing.T) {
782+ d := newRemotePickerDaemon()
783+ // An installed but unpublished monitor reads as "checking"; a nil
784+ // directory would mean remote monitoring is not installed at all.
785+ d.remoteDirectory = &stubRemoteDirectory{}
1041786 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
1042- ac.resumeCapable = true
1043- d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
1044- request := receiveRemotePicker(t, catalog.requests, "picker catalog request")
1045- d.enterPalette(sess, ac)
1046- receiveRemotePickerClose(t, request.ctx.Done(), "superseded picker catalog request")
1047- request = receiveRemotePicker(t, catalog.requests, "shared catalog request")
1048787
1049- require.True(t, d.parkAttachment(sess, ac))
1050- timer := receiveRemotePicker(t, clock.timers, "park expiry timer")
1051- timer.ch <- clock.Now()
1052- receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
788+ findChecking := func(views []picker.SessionView) bool {
789+ for _, view := range views {
790+ if view.ID == domain.SessionID("remote:checking") {
791+ if view.RemoteActivation != picker.RemoteUnavailable || !view.CannotAcceptMoves {
792+ t.Fatal("checking row must be non-actionable")
793+ }
794+ return true
795+ }
796+ }
797+ return false
798+ }
1053799
800+ views, _ := d.pickerViews(sess, ac)
801+ require.True(t, findChecking(views), "uninitialized directory must publish a checking row")
802+
803+ seedRemoteDirectory(t, d)
804+ views, _ = d.pickerViews(sess, ac)
805+ require.False(t, findChecking(views), "initialized directory must retire the checking row")
806+}
807+
808+// TestRemoteDirectoryRefreshPreservesBlockedPreview proves an unchanged
809+// directory revision does not interrupt an in-flight remote preview: the
810+// rebuild keeps the selected exact target, so the preview cancel func is
811+// never invoked and its content survives. A changed selection still
812+// re-registers (and cancels) the preview.
813+func TestRemoteDirectoryRefreshPreservesBlockedPreview(t *testing.T) {
814+ d := newRemotePickerDaemon()
815+ session := catalogue.RemoteCatalogSession{
816+ LifecycleID: remoteLifecycleForTest(), Name: "work", State: catalogue.RemoteCatalogSessionUp,
817+ Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, ActiveTabID: "tab-1",
818+ }
819+ seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(10, 0), session))
820+ sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
821+ d.publishPicker(sess, ac, d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
822+ // Move the cursor onto the remote row.
1054823 ac.overlays.pickerMu.Lock()
1055- require.Nil(t, ac.overlays.picker)
824+ for range 8 {
825+ target, ok := ac.overlays.picker.Selected()
826+ if ok && target.RemoteTarget != nil {
827+ break
828+ }
829+ ac.overlays.picker.Down()
830+ }
831+ target, ok := ac.overlays.picker.Selected()
1056832 ac.overlays.pickerMu.Unlock()
1057- ac.overlays.paletteMu.Lock()
1058- require.Nil(t, ac.overlays.palette)
1059- ac.overlays.paletteMu.Unlock()
1060- d.remoteCatalog.mu.Lock()
1061- require.NotContains(t, d.remoteCatalog.consumers, ac)
1062- d.remoteCatalog.mu.Unlock()
833+ require.True(t, ok && target.RemoteTarget != nil, "picker must offer the seeded remote row")
834+
835+ // Simulate a blocked remote preview fetch.
836+ cancelled := make(chan struct{})
837+ _, cancel := context.WithCancel(context.Background())
838+ defer cancel()
839+ ac.overlays.pickerMu.Lock()
840+ ac.overlays.pickerRemotePreviewCancel = func() {
841+ close(cancelled)
842+ cancel()
843+ }
844+ ac.overlays.pickerRemotePreview = picker.Preview{Width: 8, Height: 4}
845+ ac.overlays.pickerMu.Unlock()
846+
847+ // An identical directory revision rebuilds rows around the same
848+ // exact target: the preview survives untouched.
849+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
850+ select {
851+ case <-cancelled:
852+ t.Fatal("unchanged directory revision cancelled the blocked preview")
853+ default:
854+ }
855+ ac.overlays.pickerMu.Lock()
856+ kept := ac.overlays.pickerRemotePreview
857+ ac.overlays.pickerMu.Unlock()
858+ require.Equal(t, 8, kept.Width, "blocked preview content must survive an unchanged refresh")
859+
860+ // Removing the remote session moves the selection: the preview
861+ // re-registers and the blocked fetch is cancelled.
862+ seedRemoteDirectory(t, d)
863+ d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
864+ select {
865+ case <-cancelled:
866+ case <-time.After(5 * time.Second):
867+ t.Fatal("changed selection did not cancel the blocked preview")
868+ }
1063869 }
1064870
1065871 func TestParkedPickerRetirementClosesCapturedGenerationOnly(t *testing.T) {
1066- d := newRemotePickerDaemon(nil)
872+ d := newRemotePickerDaemon()
1067873 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
1068874 ac.resumeCapable = true
1069875 d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
@@ -1089,7 +895,7 @@ func TestParkedPickerRetirementClosesCapturedGenerationOnly(t *testing.T) {
1089895 }
1090896
1091897 func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) {
1092- d := newRemotePickerDaemon(nil)
898+ d := newRemotePickerDaemon()
1093899 firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first")
1094900 secondSession, second, _ := addRemoteRefreshPickerOwner(t, d, "second")
1095901 first.resumeCapable, second.resumeCapable = true, true
@@ -1109,7 +915,7 @@ func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) {
1109915 }
1110916
1111917 func TestParkedPickerResumePreservesGeneration(t *testing.T) {
1112- d := newRemotePickerDaemon(nil)
918+ d := newRemotePickerDaemon()
1113919 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
1114920 ac.resumeCapable = true
1115921 ac.clientID = [16]byte{1, 2, 3, 4}
@@ -1132,7 +938,7 @@ func TestParkedPickerResumePreservesGeneration(t *testing.T) {
1132938 }
1133939
1134940 func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) {
1135- d := newRemotePickerDaemon(nil)
941+ d := newRemotePickerDaemon()
1136942 sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
1137943 ac.resumeCapable = true
1138944 d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{})
@@ -1150,168 +956,3 @@ func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) {
1150956 require.Same(t, newer, ac.overlays.picker)
1151957 ac.overlays.pickerMu.Unlock()
1152958 }
1153-
1154-func TestRemoteRefreshCancellationWhenLastPickerCloses(t *testing.T) {
1155- hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
1156- d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(500, 0))
1157- newInstance := func() remotePickerInstance {
1158- ac := &attachedClient{}
1159- ac.initOverlays()
1160- model := picker.New(nil, picker.SelectionConfig{})
1161- ac.overlays.pickerMu.Lock()
1162- ac.overlays.picker = model
1163- ac.overlays.pickerGeneration++
1164- instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
1165- ac.overlays.pickerMu.Unlock()
1166- return instance
1167- }
1168- closeInstance := func(instance remotePickerInstance) {
1169- instance.ac.overlays.pickerMu.Lock()
1170- instance.ac.overlays.picker = nil
1171- instance.ac.overlays.pickerMu.Unlock()
1172- d.remoteDiscoveryClosed(instance.discoveryInstance())
1173- }
1174- first, second := newInstance(), newInstance()
1175- d.remoteDiscoveryOpened(first.discoveryInstance())
1176- request := receiveRemotePicker(t, catalog.requests, "catalog request")
1177- d.remoteDiscoveryOpened(second.discoveryInstance())
1178- receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
1179- request = receiveRemotePicker(t, catalog.requests, "catalog request")
1180-
1181- closeInstance(first)
1182- select {
1183- case <-request.ctx.Done():
1184- t.Fatal("refresh canceled while another picker remained open")
1185- default:
1186- }
1187- closeInstance(second)
1188- receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation")
1189-
1190- d.remoteCatalog.mu.Lock()
1191- require.Empty(t, d.remoteCatalog.consumers)
1192- require.Nil(t, d.remoteCatalog.cancel)
1193- d.remoteCatalog.mu.Unlock()
1194-}
1195-
1196-func TestRemoteRefreshCannotStartAfterLastPickerCloseWins(t *testing.T) {
1197- hosts := &remoteRefreshHostStore{hosts: []string{"arch"}}
1198- d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(525, 0))
1199- ac := &attachedClient{}
1200- ac.initOverlays()
1201- model := picker.New(nil, picker.SelectionConfig{})
1202- ac.overlays.pickerMu.Lock()
1203- ac.overlays.picker = model
1204- ac.overlays.pickerGeneration++
1205- instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: model}
1206- ac.overlays.pickerMu.Unlock()
1207-
1208- // Stop at the production seam after exact picker ownership registration but
1209- // before refresh startup, then let the last-picker close linearize first.
1210- require.True(t, d.registerRemoteDiscoveryConsumer(instance.discoveryInstance()))
1211- ac.overlays.pickerMu.Lock()
1212- ac.overlays.picker = nil
1213- ac.overlays.pickerMu.Unlock()
1214- d.remoteDiscoveryClosed(instance.discoveryInstance())
1215-
1216- d.remoteCatalog.mu.Lock()
1217- refreshBefore := d.remoteCatalog.refresh
1218- d.remoteCatalog.mu.Unlock()
1219- require.Zero(t, d.startRemoteDiscoveryRefresh(instance.discoveryInstance()))
1220-
1221- d.remoteCatalog.mu.Lock()
1222- require.Empty(t, d.remoteCatalog.consumers, "the closing picker must remain unregistered")
1223- require.Equal(t, refreshBefore, d.remoteCatalog.refresh, "a close that wins before startup must prevent a later refresh generation")
1224- require.Nil(t, d.remoteCatalog.cancel, "a close that wins before startup must prevent a later cancel installation")
1225- d.remoteCatalog.mu.Unlock()
1226- select {
1227- case request := <-catalog.requests:
1228- t.Fatalf("catalog call remained running after the last picker closed: %s", request.host)
1229- default:
1230- }
1231-}
1232-
1233-func TestRemotePickerStaleAfterCloseCannotRemoveReopenedPicker(t *testing.T) {
1234- d := newRemotePickerDaemon(nil)
1235- sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
1236- first := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
1237- d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
1238-
1239- staleClose := d.pickerListInputState(ac)
1240- ac.overlays.pickerMu.Lock()
1241- staleClose.closeLocked()
1242- ac.overlays.pickerMu.Unlock()
1243-
1244- reopened := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
1245- d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
1246- staleClose.afterClose()
1247-
1248- d.remoteCatalog.mu.Lock()
1249- _, registered := d.remoteCatalog.consumers[ac]
1250- d.remoteCatalog.mu.Unlock()
1251- require.True(t, registered, "stale close cleanup must retain the reopened picker registration")
1252- ac.overlays.pickerMu.Lock()
1253- require.Same(t, reopened, ac.overlays.picker)
1254- ac.overlays.pickerMu.Unlock()
1255-}
1256-
1257-func TestRemotePickerRefreshPreservesQueryEditedDuringBuild(t *testing.T) {
1258- d := newRemotePickerDaemon(nil)
1259- sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
1260- model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
1261- d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
1262- d.handlePickerInput(ac, []byte("/before"))
1263-
1264- rebuildReached := make(chan struct{})
1265- allowPublication := make(chan struct{})
1266- ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
1267- close(rebuildReached)
1268- <-allowPublication
1269- }
1270- refreshed := make(chan struct{})
1271- go func() {
1272- d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
1273- close(refreshed)
1274- }()
1275- receiveRemotePickerClose(t, rebuildReached, "picker rebuild")
1276-
1277- d.handlePickerInput(ac, []byte("-after"))
1278- close(allowPublication)
1279- receiveRemotePickerClose(t, refreshed, "picker refresh")
1280-
1281- ac.overlays.pickerMu.Lock()
1282- defer ac.overlays.pickerMu.Unlock()
1283- require.Same(t, model, ac.overlays.picker, "refresh applies rows in place so attachment-local editor state remains owned")
1284- require.Equal(t, "before-after", ac.overlays.picker.Query())
1285-}
1286-
1287-func TestRemotePickerStaleRefreshCannotOverwriteReopenedModel(t *testing.T) {
1288- d := newRemotePickerDaemon(nil)
1289- sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner")
1290- first := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
1291- d.publishPicker(sess, ac, first, pickerNavigate, moveSourceLocator{})
1292-
1293- rebuildReached := make(chan struct{})
1294- allowPublication := make(chan struct{})
1295- publicationWait := make(chan error, 1)
1296- ac.overlays.afterPickerRefreshBuild = func(*picker.Model) {
1297- close(rebuildReached)
1298- publicationWait <- waitRemotePickerClose(allowPublication, "picker publication release")
1299- }
1300- refreshed := make(chan struct{})
1301- go func() {
1302- d.refreshRemoteDiscoveryConsumers()
1303- close(refreshed)
1304- }()
1305- receiveRemotePickerClose(t, rebuildReached, "picker rebuild")
1306-
1307- d.closePicker(ac)
1308- reopened := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
1309- d.publishPicker(sess, ac, reopened, pickerNavigate, moveSourceLocator{})
1310- close(allowPublication)
1311- require.NoError(t, receiveRemotePicker(t, publicationWait, "picker publication release"))
1312- receiveRemotePickerClose(t, refreshed, "picker refresh")
1313-
1314- ac.overlays.pickerMu.Lock()
1315- require.Same(t, reopened, ac.overlays.picker, "a rebuild for the old picker must not publish into its replacement")
1316- ac.overlays.pickerMu.Unlock()
1317-}
M internal/usecase/picker/model.go +4 -4
@@ -453,13 +453,13 @@ func statusForSession(session SessionView, stopped bool) rowStatus {
453453 remote := session.RemoteKey != nil || session.RemoteTarget != nil || session.RemoteHost != ""
454454 if remote {
455455 switch session.RemoteReason {
456- case "host_unreachable":
456+ case domain.RemoteReasonHostUnreachable:
457457 return rowStatusDown
458- case "version_mismatch":
458+ case domain.RemoteReasonVersionMismatch:
459459 return rowStatusVersion
460- case "refreshing", "catalog_stale":
460+ case domain.RemoteReasonRefreshing, domain.RemoteReasonCatalogStale:
461461 return rowStatusStale
462- case "malformed", "session_broken", "identity_changed":
462+ case domain.RemoteReasonMalformed, domain.RemoteReasonSessionBroken, domain.RemoteReasonIdentityChanged, domain.RemoteReasonAuthFailure:
463463 return rowStatusError
464464 }
465465 }
M internal/usecase/picker/model_test.go +1 -1
@@ -842,7 +842,7 @@ func TestUnavailableStoppedRemoteUsesAvailabilityBadgeAndHint(t *testing.T) {
842842 lifecycle := domain.SessionLifecycleID{1}
843843 key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"}
844844 target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", Stopped: true}
845- m := New([]SessionView{{ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &target, Stopped: true, RemoteReason: "catalog_stale", RemoteDetail: "catalog stale", RemoteActivation: RemoteUnavailable}}, SelectionConfig{Mode: SelectNavigationTab})
845+ m := New([]SessionView{{ID: key.ID(), Name: key.Display(), RemoteKey: &key, RemoteTarget: &target, Stopped: true, RemoteReason: domain.RemoteReasonCatalogStale, RemoteDetail: "catalog stale", RemoteActivation: RemoteUnavailable}}, SelectionConfig{Mode: SelectNavigationTab})
846846
847847 frame := m.Render(domain.Size{Cols: 40, Rows: 3}, Preview{})
848848 require.Contains(t, rowText(frame.Row(0)), "[stale]")
A internal/usecase/remotes/monitor.go +656 -0
@@ -0,0 +1,656 @@
1+package remotes
2+
3+import (
4+ "context"
5+ "log/slog"
6+ "sort"
7+ "sync"
8+ "sync/atomic"
9+ "time"
10+
11+ "github.com/bnema/vev/internal/domain"
12+ "github.com/bnema/vev/internal/ports"
13+ "github.com/bnema/vev/internal/protocol/catalogue"
14+)
15+
16+// Monitor is the remote-specific observation owner: it runs the single remote
17+// policy loop, maintains per-registration check state, and publishes an
18+// immutable directory snapshot. It is not a reusable lifecycle engine. The
19+// constructor only sets up memory and injected ports; Run owns execution.
20+//
21+// Memory publication uses an immutable snapshot behind an atomic pointer.
22+// Policy mutation happens on the service loop. Rendering never waits for that
23+// loop, an I/O lock, or a worker.
24+type Monitor struct {
25+ runtime ports.RemoteRuntime
26+ clock ports.Clock
27+ log *slog.Logger
28+
29+ current atomic.Pointer[ports.RemoteDirectorySnapshot]
30+
31+ mu sync.Mutex
32+ revision uint64
33+ initialized bool
34+ subscribers map[*directorySubscription]struct{}
35+ reconcileCh chan struct{}
36+ // pendingVerify coalesces endpoint-specific verification requests
37+ // from RequestReconcile; pendingRegistry records a RegistryChanged
38+ // mutation hint. Both are drained by the loop; the mutex only guards
39+ // this handoff, never policy state.
40+ pendingVerify map[string]struct{}
41+ pendingRegistry bool
42+}
43+
44+// NewMonitor wires a Monitor from its runtime and clock. It performs no I/O
45+// and starts no goroutines; daemon composition owns starting and stopping it
46+// through Run.
47+func NewMonitor(runtime ports.RemoteRuntime, clock ports.Clock, log *slog.Logger) *Monitor {
48+ if log == nil {
49+ log = slog.Default()
50+ }
51+ m := &Monitor{
52+ runtime: runtime,
53+ clock: clock,
54+ log: log,
55+ subscribers: make(map[*directorySubscription]struct{}),
56+ reconcileCh: make(chan struct{}, 1),
57+ pendingVerify: make(map[string]struct{}),
58+ }
59+ m.current.Store(&ports.RemoteDirectorySnapshot{Hosts: []ports.RemoteHostSnapshot{}})
60+ return m
61+}
62+
63+var _ ports.RemoteDirectory = (*Monitor)(nil)
64+
65+// Snapshot returns a defensive copy of the newest publication. It performs no
66+// I/O and never waits for the service loop.
67+func (m *Monitor) Snapshot() ports.RemoteDirectorySnapshot {
68+ return m.current.Load().Clone()
69+}
70+
71+// Subscribe registers one capacity-one wake channel owned by the caller.
72+func (m *Monitor) Subscribe() ports.RemoteDirectorySubscription {
73+ sub := &directorySubscription{changed: make(chan struct{}, 1), onClose: m.unsubscribe}
74+ m.mu.Lock()
75+ m.subscribers[sub] = struct{}{}
76+ m.mu.Unlock()
77+ return sub
78+}
79+
80+func (m *Monitor) unsubscribe(sub *directorySubscription) {
81+ m.mu.Lock()
82+ delete(m.subscribers, sub)
83+ m.mu.Unlock()
84+}
85+
86+// RequestReconcile queues a coalesced verification hint for one endpoint.
87+// The loop checks that endpoint on its next pass without forcing a
88+// registry read; a hint for an in-flight check converts to exactly one
89+// follow-up. It never blocks and never performs a synchronous refresh.
90+func (m *Monitor) RequestReconcile(endpoint string) {
91+ m.mu.Lock()
92+ if endpoint != "" {
93+ if len(m.pendingVerify) < maxPendingVerifyHints {
94+ m.pendingVerify[endpoint] = struct{}{}
95+ }
96+ } else {
97+ m.pendingRegistry = true
98+ }
99+ m.mu.Unlock()
100+ m.hint()
101+}
102+
103+// RegistryChanged queues a coalesced immediate registry hint after a mutation
104+// in the owning process. Registry reads stay the only membership authority;
105+// verification hints are tracked separately.
106+func (m *Monitor) RegistryChanged() {
107+ m.mu.Lock()
108+ m.pendingRegistry = true
109+ m.mu.Unlock()
110+ m.hint()
111+}
112+
113+func (m *Monitor) hint() {
114+ select {
115+ case m.reconcileCh <- struct{}{}:
116+ default:
117+ }
118+}
119+
120+// drainHints moves coalesced cross-goroutine requests into loop-owned
121+// policy state. Verification hints merge per endpoint; a registry mutation
122+// hint expedites the next registry read without touching verification.
123+func (m *Monitor) drainHints(state *serviceState) {
124+ m.mu.Lock()
125+ for endpoint := range m.pendingVerify {
126+ if len(state.verifyHints) >= maxPendingVerifyHints {
127+ break
128+ }
129+ state.verifyHints[endpoint] = struct{}{}
130+ }
131+ clear(m.pendingVerify)
132+ if m.pendingRegistry {
133+ state.registryExpedite = true
134+ m.pendingRegistry = false
135+ }
136+ m.mu.Unlock()
137+}
138+
139+// Run is the single remote policy loop. It bootstraps registry and cache
140+// reads independently, admits bounded observation work, applies fenced
141+// results, and publishes snapshots. It returns when ctx is cancelled after at
142+// most two seconds of runtime cleanup joining.
143+func (m *Monitor) Run(ctx context.Context) error {
144+ if m.runtime == nil {
145+ return nil
146+ }
147+ jobs := make(chan ports.RemoteJob, maxInFlightJobs)
148+ results := make(chan ports.RemoteJobResult, maxInFlightJobs)
149+ runtimeDone := make(chan error, 1)
150+ go func() { runtimeDone <- m.runtime.Run(ctx, jobs, results) }()
151+
152+ m.loop(ctx, jobs, results)
153+
154+ shutdown := m.clock.NewTimer(2 * time.Second)
155+ defer shutdown.Stop()
156+ // The loop already exited on cancellation; wait for cooperative
157+ // runtime teardown or the bounded join, whichever comes first.
158+ // There is no early return on ctx.Done here: the context is already
159+ // cancelled, so such a branch would skip the join entirely.
160+ select {
161+ case err := <-runtimeDone:
162+ return err
163+ case <-shutdown.C():
164+ return nil
165+ }
166+}
167+
168+func (m *Monitor) loop(ctx context.Context, jobs chan<- ports.RemoteJob, results <-chan ports.RemoteJobResult) {
169+ state := newServiceState()
170+ timer := m.clock.NewTimer(0)
171+ defer timer.Stop()
172+ for {
173+ now := m.clock.Now()
174+ if !m.dispatch(ctx, now, state, jobs) {
175+ return
176+ }
177+ markExpired(now, state)
178+ if state.dirty {
179+ state.dirty = false
180+ m.publish(state)
181+ }
182+ wakeNow := m.clock.Now()
183+ wait := state.nextWakeup(wakeNow)
184+ delay := wait.Sub(wakeNow)
185+ if delay < 0 {
186+ delay = 0
187+ }
188+ timer.Reset(delay)
189+ select {
190+ case <-ctx.Done():
191+ return
192+ case <-timer.C():
193+ case <-m.reconcileCh:
194+ m.drainHints(state)
195+ case result, ok := <-results:
196+ if !ok {
197+ return
198+ }
199+ m.applyResult(m.clock.Now(), state, result)
200+ }
201+ }
202+}
203+
204+// dispatch admits due work within lane credits. A successful job-channel send
205+// transfers exactly one credit until exactly one completion retires it. It
206+// reports false when cancellation wins over admission: the loop must stop.
207+// Observations dispatch oldest-due-first with an endpoint tiebreak so a busy
208+// pool cannot starve the tail while the head recycles.
209+func (m *Monitor) dispatch(ctx context.Context, now time.Time, state *serviceState, jobs chan<- ports.RemoteJob) bool {
210+ admit := func(job ports.RemoteJob) bool {
211+ select {
212+ case jobs <- job:
213+ return true
214+ case <-ctx.Done():
215+ return false
216+ }
217+ }
218+ if state.registryCredit && (!state.registryDone || state.registryExpedite || now.Sub(state.lastRegistryRead) >= registryPollInterval) {
219+ if state.registryExpedite {
220+ state.expeditedRead = true
221+ }
222+ state.registryCredit = false
223+ state.registryExpedite = false
224+ if !admit(ports.RemoteJob{Kind: ports.RemoteJobRegistryRead}) {
225+ return false
226+ }
227+ }
228+ if state.cacheCredit && !state.cacheDone && !now.Before(state.cacheRetryAt) {
229+ state.cacheCredit = false
230+ if !admit(ports.RemoteJob{Kind: ports.RemoteJobCacheLoad}) {
231+ return false
232+ }
233+ }
234+ // Verification hints convert to exactly one follow-up when the
235+ // endpoint's own request is still outstanding, and are consumed.
236+ for endpoint, host := range state.hosts {
237+ if _, hinted := state.verifyHints[endpoint]; !hinted {
238+ continue
239+ }
240+ if rec, outstanding := state.inFlight[endpoint]; outstanding {
241+ if rec.registration.Equal(host.registration) {
242+ host.followUp = true
243+ }
244+ delete(state.verifyHints, endpoint)
245+ } else if host.observing {
246+ host.followUp = true
247+ delete(state.verifyHints, endpoint)
248+ }
249+ }
250+ type dueCandidate struct {
251+ endpoint string
252+ due time.Time
253+ }
254+ var candidates []dueCandidate
255+ for endpoint, host := range state.hosts {
256+ if _, outstanding := state.inFlight[endpoint]; outstanding || host.observing {
257+ continue
258+ }
259+ _, hinted := state.verifyHints[endpoint]
260+ if !hinted && now.Before(host.nextDue) {
261+ continue
262+ }
263+ due := host.nextDue
264+ if hinted {
265+ due = time.Time{}
266+ }
267+ candidates = append(candidates, dueCandidate{endpoint: endpoint, due: due})
268+ }
269+ sort.Slice(candidates, func(i, j int) bool {
270+ if candidates[i].due.Equal(candidates[j].due) {
271+ return candidates[i].endpoint < candidates[j].endpoint
272+ }
273+ return candidates[i].due.Before(candidates[j].due)
274+ })
275+ for _, candidate := range candidates {
276+ if state.observeCredits <= 0 {
277+ break
278+ }
279+ host := state.hosts[candidate.endpoint]
280+ if host == nil {
281+ continue
282+ }
283+ delete(state.verifyHints, candidate.endpoint)
284+ host.attempts++
285+ host.observing = true
286+ host.observeGeneration = domain.RemoteGeneration(host.attempts)
287+ host.lastAttempt = now
288+ state.inFlight[candidate.endpoint] = inFlightRequest{
289+ registration: host.registration,
290+ generation: host.observeGeneration,
291+ }
292+ state.observeCredits--
293+ // Admission is itself a visible transition: the host starts
294+ // checking. Publish it rather than leaving the check
295+ // invisible until an unrelated revision.
296+ state.dirty = true
297+ if !admit(ports.RemoteJob{
298+ Kind: ports.RemoteJobCatalogObserve,
299+ Endpoint: candidate.endpoint,
300+ Registration: host.registration,
301+ Generation: host.observeGeneration,
302+ Deadline: observeTimeout,
303+ }) {
304+ return false
305+ }
306+ }
307+ if state.storeCredit && !state.storeActive && state.storePending && !now.Before(state.storeRetryAt) {
308+ state.storeCredit = false
309+ state.storeActive = true
310+ entries := state.pendingStore
311+ state.pendingStore = nil
312+ state.storePending = false
313+ state.stored = entries
314+ if !admit(ports.RemoteJob{Kind: ports.RemoteJobCacheStore, Entries: entries}) {
315+ return false
316+ }
317+ }
318+ return true
319+}
320+
321+// applyResult retires one admission credit and applies fenced results.
322+// Observation completions first retire the exact admitted request: unknown
323+// or superseded requests retire their credit without touching policy
324+// state, and a result for a removed or re-registered endpoint fences out
325+// entirely instead of mutating the new registration.
326+func (m *Monitor) applyResult(now time.Time, state *serviceState, result ports.RemoteJobResult) {
327+ switch result.Kind {
328+ case ports.RemoteJobRegistryRead:
329+ state.registryCredit = true
330+ state.lastRegistryRead = now
331+ expedited := state.expeditedRead
332+ state.expeditedRead = false
333+ if result.Err != nil {
334+ // Registry load failure preserves known registrations and
335+ // retries on the bounded poll cadence instead of spinning:
336+ // the attempt completed, so only the load error publishes.
337+ state.registryDone = true
338+ state.registryError = result.Err
339+ state.lastRegistryRead = now
340+ state.dirty = true
341+ return
342+ }
343+ state.registryDone = true
344+ state.registryInit = true
345+ state.registryError = nil
346+ if m.reconcileRegistrations(now, state, result.Registrations) {
347+ state.dirty = true
348+ }
349+ if expedited {
350+ // A reconcile hint during an in-flight check permits one
351+ // follow-up, not another concurrent check: only checks
352+ // still bound to the current registration qualify.
353+ for endpoint, host := range state.hosts {
354+ if !host.observing {
355+ continue
356+ }
357+ if rec, ok := state.inFlight[endpoint]; ok && rec.registration.Equal(host.registration) {
358+ host.followUp = true
359+ }
360+ }
361+ }
362+ case ports.RemoteJobCacheLoad:
363+ state.cacheCredit = true
364+ state.cacheDone = true
365+ if result.Err != nil {
366+ m.log.Debug("remote cache load failed, retrying on bounded cadence", "err", result.Err)
367+ state.cacheDone = false
368+ state.cacheRetryAt = now.Add(registryPollInterval)
369+ state.dirty = true
370+ return
371+ }
372+ state.pendingCache = result.Entries
373+ m.seedPendingCache(state)
374+ state.dirty = true
375+ case ports.RemoteJobCatalogObserve:
376+ if state.observeCredits < maxObserveCredits {
377+ state.observeCredits++
378+ }
379+ request, outstanding := state.inFlight[result.Endpoint]
380+ if !outstanding {
381+ // Unknown host or late duplicate completion: retire the
382+ // credit without touching policy state.
383+ return
384+ }
385+ if request.generation != result.Generation || !request.registration.Equal(result.Registration) {
386+ // Not the admitted request: a stale duplicate or foreign
387+ // result. It retires no ownership, so the outstanding
388+ // request stays fenced until its own completion.
389+ return
390+ }
391+ delete(state.inFlight, result.Endpoint)
392+ host := state.hosts[result.Endpoint]
393+ if host == nil || !host.registration.Equal(request.registration) {
394+ // Removed or re-registered while in flight: the new policy
395+ // is already due and needs no poke from the old request.
396+ return
397+ }
398+ host.observing = false
399+ if result.Err != nil {
400+ m.applyObserveFailure(now, state, host, result)
401+ return
402+ }
403+ m.applyObserveSuccess(now, state, host, result)
404+ case ports.RemoteJobCacheStore:
405+ state.storeCredit = true
406+ state.storeActive = false
407+ failed := state.stored
408+ state.stored = nil
409+ if result.Err != nil {
410+ m.log.Debug("remote cache store failed, retrying on bounded cadence", "err", result.Err)
411+ if !state.storePending {
412+ // Nothing newer superseded the failed payload: retry
413+ // it once the backoff elapses instead of hot-looping.
414+ state.pendingStore = failed
415+ state.storePending = true
416+ }
417+ state.storeRetryAt = now.Add(registryPollInterval)
418+ } else {
419+ state.lastPersisted = failed
420+ }
421+ }
422+}
423+
424+// reconcileRegistrations diffs a fresh registry read against policy state.
425+// Added endpoints start unknown and due immediately; removed endpoints drop
426+// their policy but never their outstanding request ownership, so in-flight
427+// results fence out; re-added endpoints with a new incarnation reset as new
428+// registrations with observing cleared, since any outstanding check belongs
429+// to the previous registration. It reports whether membership, identity or
430+// rank changed so identical polls never publish a revision on their own.
431+func (m *Monitor) reconcileRegistrations(now time.Time, state *serviceState, registrations []domain.RemoteRegistration) bool {
432+ seen := make(map[string]struct{}, len(registrations))
433+ changed := false
434+ for i, record := range registrations {
435+ if record.IsZero() {
436+ continue
437+ }
438+ seen[record.Endpoint] = struct{}{}
439+ host, ok := state.hosts[record.Endpoint]
440+ if !ok {
441+ state.hosts[record.Endpoint] = &hostPolicy{
442+ registration: record,
443+ rank: i,
444+ availability: domain.RemoteAvailabilityUnknown,
445+ nextDue: now,
446+ }
447+ changed = true
448+ continue
449+ }
450+ if host.rank != i {
451+ host.rank = i
452+ changed = true
453+ }
454+ if host.registration.Equal(record) {
455+ host.registration = record
456+ continue
457+ }
458+ state.hosts[record.Endpoint] = &hostPolicy{
459+ registration: record,
460+ rank: i,
461+ availability: domain.RemoteAvailabilityUnknown,
462+ nextDue: now,
463+ }
464+ changed = true
465+ }
466+ for endpoint := range state.hosts {
467+ if _, ok := seen[endpoint]; !ok {
468+ delete(state.hosts, endpoint)
469+ changed = true
470+ }
471+ }
472+ for hinted := range state.verifyHints {
473+ if _, ok := seen[hinted]; !ok {
474+ delete(state.verifyHints, hinted)
475+ }
476+ }
477+ if m.seedPendingCache(state) {
478+ changed = true
479+ }
480+ m.queueStore(state)
481+ return changed
482+}
483+
484+// seedPendingCache applies held cache entries to matching registrations that
485+// have no newer successful inventory, preserving the cached fetch time as
486+// the inventory age. It reports whether any entry became visible, so the
487+// registry path publishes seeded sessions even when the registrations
488+// themselves are unchanged. Unbound entries and entries for unknown endpoints are
489+// ignored, never assigned to a different registration.
490+func (m *Monitor) seedPendingCache(state *serviceState) bool {
491+ if len(state.pendingCache) == 0 || !state.registryDone {
492+ return false
493+ }
494+ remaining := make([]catalogue.RemoteCatalogCacheEntry, 0, len(state.pendingCache))
495+ seeded := false
496+ for _, entry := range state.pendingCache {
497+ if entry.Incarnation == [16]byte{} {
498+ continue
499+ }
500+ host, ok := state.hosts[entry.Host]
501+ if !ok || host.inventoryConfirmed || host.cacheSeeded || host.registration.Incarnation != entry.Incarnation {
502+ remaining = append(remaining, entry)
503+ continue
504+ }
505+ host.sessions = append([]catalogue.RemoteCatalogSession(nil), entry.Sessions...)
506+ host.inventoryKnown = true
507+ host.lastSuccess = entry.FetchedAt
508+ host.cacheSeeded = true
509+ seeded = true
510+ }
511+ state.pendingCache = remaining
512+ return seeded
513+}
514+
515+// applyObserveSuccess records a reachable host, replaces inventory on a
516+// successful empty response, and schedules the next healthy check with
517+// deterministic jitter.
518+func (m *Monitor) applyObserveSuccess(now time.Time, state *serviceState, host *hostPolicy, result ports.RemoteJobResult) {
519+ host.availability = domain.RemoteAvailabilityReachable
520+ host.consecutiveFailures = 0
521+ host.lastFailure = domain.RemoteFailure{}
522+ host.lastSuccess = now
523+ host.sessions = append([]catalogue.RemoteCatalogSession(nil), result.Catalog.Sessions...)
524+ host.inventoryKnown = true
525+ host.inventoryConfirmed = true
526+ host.expiryNotified = false
527+ if host.followUp {
528+ host.followUp = false
529+ host.nextDue = now
530+ } else {
531+ host.nextDue = now.Add(jittered(healthyCheckInterval, host.registration.Endpoint, host.attempts))
532+ }
533+ m.queueStore(state)
534+ state.dirty = true
535+}
536+
537+// applyObserveFailure retains prior inventory with its age, records the typed
538+// failure, and schedules a capped retry with deterministic jitter.
539+func (m *Monitor) applyObserveFailure(now time.Time, state *serviceState, host *hostPolicy, result ports.RemoteJobResult) {
540+ kind := result.FailureKind
541+ if kind == domain.RemoteFailureNone {
542+ kind = domain.RemoteFailureTransport
543+ }
544+ host.availability = availabilityFor(kind)
545+ host.consecutiveFailures++
546+ host.lastFailure = domain.RemoteFailure{Kind: kind, Err: result.Err}
547+ if host.followUp {
548+ host.followUp = false
549+ host.nextDue = now
550+ } else {
551+ host.nextDue = now.Add(jittered(retryDelay(host.consecutiveFailures), host.registration.Endpoint, host.attempts))
552+ }
553+ state.dirty = true
554+}
555+
556+// queueStore stages the newest known inventories for serialized
557+// persistence, superseding any unstarted pending snapshot. Confirmed and
558+// seeded inventories are both retained so one host's successful check never
559+// discards another host's still-useful cached rows; unchanged payloads
560+// never stage duplicate writes. A blocked writer never blocks registry or
561+// observation lanes.
562+func (m *Monitor) queueStore(state *serviceState) {
563+ entries := make([]catalogue.RemoteCatalogCacheEntry, 0, len(state.hosts))
564+ for _, endpoint := range state.orderedEndpoints() {
565+ host := state.hosts[endpoint]
566+ if !host.inventoryKnown {
567+ continue
568+ }
569+ entries = append(entries, catalogue.RemoteCatalogCacheEntry{
570+ Host: endpoint,
571+ FetchedAt: host.lastSuccess,
572+ Incarnation: host.registration.Incarnation,
573+ Sessions: append([]catalogue.RemoteCatalogSession(nil), host.sessions...),
574+ })
575+ }
576+ if len(entries) == 0 && len(state.lastPersisted) == 0 {
577+ // Initial emptiness is not a write: only a removal that
578+ // drops a previously persisted payload stages an empty
579+ // snapshot (distinguished by lastPersisted above).
580+ return
581+ }
582+ if state.storePending && cacheEntriesEqual(entries, state.pendingStore) {
583+ return
584+ }
585+ if !state.storePending && state.storeActive && cacheEntriesEqual(entries, state.stored) {
586+ return
587+ }
588+ if !state.storePending && !state.storeActive && cacheEntriesEqual(entries, state.lastPersisted) {
589+ return
590+ }
591+ state.pendingStore = entries
592+ state.storePending = true
593+}
594+
595+// markExpired flags newly elapsed inventories so expiry publishes a
596+// revision on its own transition instead of riding an unrelated one.
597+func markExpired(now time.Time, state *serviceState) {
598+ for _, host := range state.hosts {
599+ if host.inventoryConfirmed && !host.expiryNotified && !now.Before(host.lastSuccess.Add(inventoryExpiryAge)) {
600+ state.dirty = true
601+ }
602+ }
603+}
604+
605+// publish renders and installs a new immutable snapshot, then wakes
606+// subscribers without blocking.
607+func (m *Monitor) publish(state *serviceState) {
608+ m.mu.Lock()
609+ m.revision++
610+ revision := m.revision
611+ m.initialized = m.initialized || state.registryInit
612+ initialized := m.initialized
613+ subscribers := make([]*directorySubscription, 0, len(m.subscribers))
614+ for sub := range m.subscribers {
615+ subscribers = append(subscribers, sub)
616+ }
617+ m.mu.Unlock()
618+ m.current.Store(snapshotPointer(state.buildSnapshot(revision, initialized)))
619+ for _, sub := range subscribers {
620+ select {
621+ case sub.changed <- struct{}{}:
622+ default:
623+ }
624+ }
625+ // Inventory expiry publishes a revision independently of overlay
626+ // activity: mark notified episodes after publication.
627+ now := m.clock.Now()
628+ for _, host := range state.hosts {
629+ if host.inventoryConfirmed && !host.expiryNotified && !now.Before(host.lastSuccess.Add(inventoryExpiryAge)) {
630+ host.expiryNotified = true
631+ }
632+ }
633+}
634+
635+func snapshotPointer(snapshot ports.RemoteDirectorySnapshot) *ports.RemoteDirectorySnapshot {
636+ return &snapshot
637+}
638+
639+// directorySubscription is one subscriber-owned capacity-one wake channel.
640+type directorySubscription struct {
641+ changed chan struct{}
642+ once sync.Once
643+ onClose func(*directorySubscription)
644+}
645+
646+// Changed wakes when a newer snapshot is available.
647+func (s *directorySubscription) Changed() <-chan struct{} { return s.changed }
648+
649+// Close unregisters the subscription. It is idempotent.
650+func (s *directorySubscription) Close() {
651+ s.once.Do(func() {
652+ if s.onClose != nil {
653+ s.onClose(s)
654+ }
655+ })
656+}
A internal/usecase/remotes/monitor_runtime_test.go +245 -0
@@ -0,0 +1,245 @@
1+package remotes
2+
3+import (
4+ "context"
5+ "io"
6+ "log/slog"
7+ "path/filepath"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/bnema/vev/internal/domain"
13+ "github.com/bnema/vev/internal/ports"
14+ "github.com/bnema/vev/internal/protocol/catalogue"
15+
16+ remoteadapter "github.com/bnema/vev/internal/adapters/remote"
17+)
18+
19+// gateCatalog is a scripted RemoteCatalogClient: observations block until the
20+// test answers or the observation context ends.
21+type gateCatalog struct {
22+ mu sync.Mutex
23+ calls []string
24+ request chan gateRequest
25+}
26+
27+type gateRequest struct {
28+ ctx context.Context
29+ target string
30+ reply chan gateReply
31+}
32+
33+type gateReply struct {
34+ catalog catalogue.RemoteCatalog
35+ err error
36+}
37+
38+func newGateCatalog() *gateCatalog {
39+ return &gateCatalog{request: make(chan gateRequest)}
40+}
41+
42+func (c *gateCatalog) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) {
43+ c.mu.Lock()
44+ c.calls = append(c.calls, target)
45+ c.mu.Unlock()
46+ reply := make(chan gateReply, 1)
47+ select {
48+ case c.request <- gateRequest{ctx: ctx, target: target, reply: reply}:
49+ case <-ctx.Done():
50+ return catalogue.RemoteCatalog{}, ctx.Err()
51+ }
52+ select {
53+ case answer := <-reply:
54+ return answer.catalog, answer.err
55+ case <-ctx.Done():
56+ return catalogue.RemoteCatalog{}, ctx.Err()
57+ }
58+}
59+
60+func (c *gateCatalog) callCount() int {
61+ c.mu.Lock()
62+ defer c.mu.Unlock()
63+ return len(c.calls)
64+}
65+
66+func receiveGateRequest(t *testing.T, catalog *gateCatalog, what string) gateRequest {
67+ t.Helper()
68+ select {
69+ case request := <-catalog.request:
70+ return request
71+ case <-time.After(10 * time.Second):
72+ t.Fatalf("timed out waiting for %s", what)
73+ return gateRequest{}
74+ }
75+}
76+
77+// waitSnapshot polls the monitor publication until the desired state holds.
78+// Publications may coalesce, so integration sync keys on state, not wakes.
79+func waitSnapshot(t *testing.T, monitor *Monitor, what string, want func(ports.RemoteDirectorySnapshot) bool) ports.RemoteDirectorySnapshot {
80+ t.Helper()
81+ deadline := time.After(10 * time.Second)
82+ for {
83+ snapshot := monitor.Snapshot()
84+ if want(snapshot) {
85+ return snapshot
86+ }
87+ select {
88+ case <-time.After(20 * time.Millisecond):
89+ case <-deadline:
90+ t.Fatalf("timed out waiting for %s: %+v", what, snapshot)
91+ return ports.RemoteDirectorySnapshot{}
92+ }
93+ }
94+}
95+
96+// TestMonitorRuntimeIntegration drives the real registry, cache and runtime
97+// adapters through the Monitor policy loop: bootstrap from disk, fair
98+// observation across hosts, successful persistence, backward-clock safety
99+// and cooperative cancellation with no stranded work.
100+func TestMonitorRuntimeIntegration(t *testing.T) {
101+ // The state directory must not exist yet so safedir creates it private.
102+ stateDir := filepath.Join(t.TempDir(), "vev")
103+ store := remoteadapter.NewFileHostStore(remoteadapter.HostStorePath(stateDir))
104+ if err := store.AddPinned("user@arch"); err != nil {
105+ t.Fatalf("AddPinned: %v", err)
106+ }
107+ if err := store.Remember("user@mule"); err != nil {
108+ t.Fatalf("Remember: %v", err)
109+ }
110+ pinned, learned, err := store.Hosts()
111+ if err != nil || len(pinned) != 1 || len(learned) != 1 {
112+ t.Fatalf("Hosts() = %+v, %+v, %v", pinned, learned, err)
113+ }
114+ archRegistration, muleRegistration := pinned[0], learned[0]
115+
116+ cache := remoteadapter.NewFileCatalogCache(remoteadapter.CatalogCachePath(stateDir))
117+ now := time.Unix(2_000, 0)
118+ if err := cache.Store([]catalogue.RemoteCatalogCacheEntry{
119+ {
120+ Host: "user@arch", FetchedAt: now, Incarnation: archRegistration.Incarnation,
121+ Sessions: []catalogue.RemoteCatalogSession{{LifecycleID: domain.SessionLifecycleID{1}, Name: "cached", State: catalogue.RemoteCatalogSessionUp, Tabs: []catalogue.RemoteCatalogTab{{ID: "t-cached"}}}},
122+ },
123+ {
124+ Host: "user@mule", FetchedAt: now, Incarnation: [16]byte{9},
125+ Sessions: []catalogue.RemoteCatalogSession{{LifecycleID: domain.SessionLifecycleID{2}, Name: "ghost", State: catalogue.RemoteCatalogSessionDown, Tabs: []catalogue.RemoteCatalogTab{{ID: "t-ghost"}}}},
126+ },
127+ }); err != nil {
128+ t.Fatalf("Store: %v", err)
129+ }
130+
131+ gate := newGateCatalog()
132+ log := slog.New(slog.NewTextHandler(io.Discard, nil))
133+ clock := newFakeClock(now)
134+ runtime := remoteadapter.NewRuntime(store, gate, cache, log)
135+ monitor := NewMonitor(runtime, clock, log)
136+ ctx, cancel := context.WithCancel(context.Background())
137+ defer cancel()
138+ done := make(chan error, 1)
139+ go func() { done <- monitor.Run(ctx) }()
140+ t.Cleanup(func() {
141+ cancel()
142+ select {
143+ case <-done:
144+ case <-time.After(10 * time.Second):
145+ t.Fatalf("monitor did not stop")
146+ }
147+ })
148+ sub := monitor.Subscribe()
149+ defer sub.Close()
150+
151+ // Both hosts are admitted fairly: one observation each, in rank order.
152+ first := receiveGateRequest(t, gate, "first observe")
153+ second := receiveGateRequest(t, gate, "second observe")
154+ observed := map[string]gateRequest{first.target: first, second.target: second}
155+ if len(observed) != 2 {
156+ t.Fatalf("observed targets = %v, want both hosts", observed)
157+ }
158+ deadline, ok := first.ctx.Deadline()
159+ if !ok || time.Until(deadline) <= 0 || time.Until(deadline) > 10*time.Second {
160+ t.Fatalf("observe ctx deadline in %v, want bounded 10s observation", time.Until(deadline))
161+ }
162+
163+ // Matching cache seeds arch; the mismatched mule entry never seeds.
164+ // Registry and cache complete independently and their publications
165+ // may coalesce, so synchronize on snapshot state, not wake counts.
166+ snapshot := waitSnapshot(t, monitor, "seeded bootstrap", func(snapshot ports.RemoteDirectorySnapshot) bool {
167+ if !snapshot.Initialized {
168+ return false
169+ }
170+ arch, ok := snapshot.Find("user@arch")
171+ if !ok || !arch.InventoryKnown {
172+ return false
173+ }
174+ _, ok = snapshot.Find("user@mule")
175+ return ok
176+ })
177+ arch, ok := snapshot.Find("user@arch")
178+ if !ok || !arch.InventoryKnown || len(arch.Sessions) != 1 || arch.Sessions[0].Name != "cached" {
179+ t.Fatalf("arch = %+v, want seeded cached inventory", arch)
180+ }
181+ mule, ok := snapshot.Find("user@mule")
182+ if !ok || mule.InventoryKnown {
183+ t.Fatalf("mule = %+v, want no mismatched seeding", mule)
184+ }
185+ if mule.Registration != muleRegistration {
186+ t.Fatalf("mule registration = %+v, want %+v", mule.Registration, muleRegistration)
187+ }
188+ if arch.Rank != 0 || mule.Rank != 1 {
189+ t.Fatalf("ranks = %d, %d, want pinned-then-learned 0, 1", arch.Rank, mule.Rank)
190+ }
191+
192+ // Successful observations publish reachability and persist bound
193+ // inventories through the real cache file.
194+ observed["user@arch"].reply <- gateReply{catalog: catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{
195+ {LifecycleID: domain.SessionLifecycleID{1}, Name: "work", State: catalogue.RemoteCatalogSessionUp, Tabs: []catalogue.RemoteCatalogTab{{ID: "t-work"}}},
196+ }}}
197+ observed["user@mule"].reply <- gateReply{err: errMonitorTestFailure}
198+ snapshot = waitSnapshot(t, monitor, "observations", func(snapshot ports.RemoteDirectorySnapshot) bool {
199+ arch, ok := snapshot.Find("user@arch")
200+ if !ok || arch.Availability != domain.RemoteAvailabilityReachable || len(arch.Sessions) != 1 || arch.Sessions[0].Name != "work" {
201+ return false
202+ }
203+ mule, ok := snapshot.Find("user@mule")
204+ return ok && mule.Availability == domain.RemoteAvailabilityUnreachable && mule.ConsecutiveFailures == 1
205+ })
206+ arch, _ = snapshot.Find("user@arch")
207+ mule, _ = snapshot.Find("user@mule")
208+
209+ var persisted []catalogue.RemoteCatalogCacheEntry
210+ persistDeadline := time.After(10 * time.Second)
211+ for {
212+ var err error
213+ persisted, err = cache.Load()
214+ if err != nil {
215+ t.Fatalf("Load: %v", err)
216+ }
217+ if len(persisted) == 1 && persisted[0].Host == "user@arch" && len(persisted[0].Sessions) == 1 && persisted[0].Sessions[0].Name == "work" {
218+ break
219+ }
220+ select {
221+ case <-time.After(20 * time.Millisecond):
222+ case <-persistDeadline:
223+ t.Fatalf("persisted = %+v, want bound arch inventory only", persisted)
224+ }
225+ }
226+ if persisted[0].Incarnation != archRegistration.Incarnation {
227+ t.Fatalf("persisted incarnation = %x, want %x", persisted[0].Incarnation, archRegistration.Incarnation)
228+ }
229+
230+ // A backward clock advance schedules no new work and changes nothing.
231+ calls := gate.callCount()
232+ clock.Advance(-time.Hour)
233+ select {
234+ case request := <-gate.request:
235+ t.Fatalf("backward clock admitted observe for %q", request.target)
236+ case <-time.After(200 * time.Millisecond):
237+ }
238+ if gate.callCount() != calls {
239+ t.Fatal("backward clock caused new observations")
240+ }
241+
242+ // Cooperative cancellation strands nothing: cancel here and let the
243+ // cleanup below assert that Run returns (single owner of done).
244+ cancel()
245+}
A internal/usecase/remotes/monitor_test.go +1117 -0
@@ -0,0 +1,1117 @@
1+package remotes
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/internal/ports"
12+ "github.com/bnema/vev/internal/protocol/catalogue"
13+)
14+
15+var errMonitorTestFailure = errors.New("monitor test failure")
16+
17+// fakeTimer is a manually fired ports.Timer.
18+type fakeTimer struct {
19+ clock *fakeClock
20+ ch chan time.Time
21+ deadline time.Time
22+ stopped bool
23+}
24+
25+func (t *fakeTimer) C() <-chan time.Time { return t.ch }
26+
27+func (t *fakeTimer) Reset(d time.Duration) bool {
28+ t.clock.mu.Lock()
29+ defer t.clock.mu.Unlock()
30+ t.deadline = t.clock.now.Add(d)
31+ t.stopped = false
32+ return true
33+}
34+
35+func (t *fakeTimer) Stop() bool {
36+ t.clock.mu.Lock()
37+ defer t.clock.mu.Unlock()
38+ t.stopped = true
39+ return true
40+}
41+
42+// fakeClock is a manually advanced ports.Clock.
43+type fakeClock struct {
44+ mu sync.Mutex
45+ now time.Time
46+ timers []*fakeTimer
47+}
48+
49+func newFakeClock(now time.Time) *fakeClock { return &fakeClock{now: now} }
50+
51+func (c *fakeClock) Now() time.Time {
52+ c.mu.Lock()
53+ defer c.mu.Unlock()
54+ return c.now
55+}
56+
57+func (c *fakeClock) NewTimer(d time.Duration) ports.Timer {
58+ c.mu.Lock()
59+ defer c.mu.Unlock()
60+ t := &fakeTimer{clock: c, ch: make(chan time.Time, 1), deadline: c.now.Add(d)}
61+ c.timers = append(c.timers, t)
62+ return t
63+}
64+
65+// Advance moves the clock forward and fires due timers without blocking.
66+func (c *fakeClock) Advance(d time.Duration) {
67+ c.mu.Lock()
68+ defer c.mu.Unlock()
69+ c.now = c.now.Add(d)
70+ for _, t := range c.timers {
71+ if t.stopped || t.deadline.After(c.now) {
72+ continue
73+ }
74+ select {
75+ case t.ch <- c.now:
76+ default:
77+ }
78+ }
79+}
80+
81+// fakeRuntime is a scripted ports.RemoteRuntime: the test observes admitted
82+// jobs and feeds completions explicitly.
83+type fakeRuntime struct {
84+ incoming chan ports.RemoteJob
85+ outgoing chan ports.RemoteJobResult
86+ err error
87+}
88+
89+func newFakeRuntime() *fakeRuntime {
90+ return &fakeRuntime{
91+ incoming: make(chan ports.RemoteJob, 16),
92+ outgoing: make(chan ports.RemoteJobResult, 16),
93+ }
94+}
95+
96+func (f *fakeRuntime) Run(ctx context.Context, jobs <-chan ports.RemoteJob, results chan<- ports.RemoteJobResult) error {
97+ for {
98+ select {
99+ case <-ctx.Done():
100+ return f.err
101+ case job, ok := <-jobs:
102+ if !ok {
103+ return f.err
104+ }
105+ select {
106+ case f.incoming <- job:
107+ case <-ctx.Done():
108+ return f.err
109+ }
110+ case res, ok := <-f.outgoing:
111+ if !ok {
112+ return f.err
113+ }
114+ select {
115+ case results <- res:
116+ case <-ctx.Done():
117+ return f.err
118+ }
119+ }
120+ }
121+}
122+
123+func testRegistration(endpoint string, incarnation byte) domain.RemoteRegistration {
124+ return domain.RemoteRegistration{Endpoint: endpoint, Incarnation: [16]byte{incarnation}, Generation: 1}
125+}
126+
127+func receiveJob(t *testing.T, ch <-chan ports.RemoteJob, what string) ports.RemoteJob {
128+ t.Helper()
129+ select {
130+ case job := <-ch:
131+ return job
132+ case <-time.After(5 * time.Second):
133+ t.Fatalf("timed out waiting for %s", what)
134+ return ports.RemoteJob{}
135+ }
136+}
137+
138+// receiveObserve returns the next admitted catalogue observation,
139+// skipping independent lane traffic such as registry polls or stores.
140+func receiveObserve(t *testing.T, ch <-chan ports.RemoteJob, what string) ports.RemoteJob {
141+ t.Helper()
142+ deadline := time.After(5 * time.Second)
143+ for {
144+ select {
145+ case job := <-ch:
146+ if job.Kind == ports.RemoteJobCatalogObserve {
147+ return job
148+ }
149+ case <-deadline:
150+ t.Fatalf("timed out waiting for %s", what)
151+ return ports.RemoteJob{}
152+ }
153+ }
154+}
155+
156+func requireNoJob(t *testing.T, ch <-chan ports.RemoteJob, what string) {
157+ t.Helper()
158+ select {
159+ case job := <-ch:
160+ t.Fatalf("unexpected %s: %+v", what, job)
161+ case <-time.After(100 * time.Millisecond):
162+ }
163+}
164+
165+// requireNoObserve drains lane traffic for a window and fails only on a
166+// duplicate catalogue observation: registry polls and cache stores are
167+// legitimate concurrent lane activity.
168+func requireNoObserve(t *testing.T, ch <-chan ports.RemoteJob, what string) {
169+ t.Helper()
170+ deadline := time.After(150 * time.Millisecond)
171+ for {
172+ select {
173+ case job := <-ch:
174+ if job.Kind == ports.RemoteJobCatalogObserve {
175+ t.Fatalf("unexpected %s: %+v", what, job)
176+ }
177+ case <-deadline:
178+ return
179+ }
180+ }
181+}
182+
183+func waitWake(t *testing.T, ch <-chan struct{}, what string) {
184+ t.Helper()
185+ select {
186+ case <-ch:
187+ case <-time.After(5 * time.Second):
188+ t.Fatalf("timed out waiting for %s", what)
189+ }
190+}
191+
192+func startMonitor(t *testing.T, clock *fakeClock, runtime *fakeRuntime) (*Monitor, context.CancelFunc) {
193+ t.Helper()
194+ monitor := NewMonitor(runtime, clock, nil)
195+ ctx, cancel := context.WithCancel(context.Background())
196+ done := make(chan error, 1)
197+ go func() { done <- monitor.Run(ctx) }()
198+ t.Cleanup(func() {
199+ cancel()
200+ select {
201+ case <-done:
202+ case <-time.After(5 * time.Second):
203+ t.Fatalf("monitor did not stop")
204+ }
205+ })
206+ return monitor, cancel
207+}
208+
209+func TestMonitorInitialSnapshotIsRenderable(t *testing.T) {
210+ monitor := NewMonitor(newFakeRuntime(), newFakeClock(time.Unix(1_000, 0)), nil)
211+ snapshot := monitor.Snapshot()
212+ if snapshot.Revision != 0 || snapshot.Initialized || len(snapshot.Hosts) != 0 {
213+ t.Fatalf("initial snapshot = %+v, want empty uninitialized", snapshot)
214+ }
215+ sub := monitor.Subscribe()
216+ defer sub.Close()
217+ select {
218+ case <-sub.Changed():
219+ t.Fatal("no publication expected before Run")
220+ case <-time.After(50 * time.Millisecond):
221+ }
222+}
223+
224+func TestMonitorBootstrapsRegistryAndCacheIndependently(t *testing.T) {
225+ clock := newFakeClock(time.Unix(1_000, 0))
226+ runtime := newFakeRuntime()
227+ monitor, _ := startMonitor(t, clock, runtime)
228+ sub := monitor.Subscribe()
229+ defer sub.Close()
230+
231+ registryJob := receiveJob(t, runtime.incoming, "registry read")
232+ if registryJob.Kind != ports.RemoteJobRegistryRead {
233+ t.Fatalf("first job = %v, want registry_read", registryJob.Kind)
234+ }
235+ cacheJob := receiveJob(t, runtime.incoming, "cache load")
236+ if cacheJob.Kind != ports.RemoteJobCacheLoad {
237+ t.Fatalf("second job = %v, want cache_load", cacheJob.Kind)
238+ }
239+
240+ // Cache answers first: the snapshot publishes but stays uninitialized
241+ // with no hosts until the registry is authoritative.
242+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad, Entries: []catalogue.RemoteCatalogCacheEntry{{
243+ Host: "user@arch", FetchedAt: clock.Now(), Incarnation: [16]byte{1},
244+ Sessions: []catalogue.RemoteCatalogSession{{Name: "cached", State: catalogue.RemoteCatalogSessionUp}},
245+ }}}
246+ waitWake(t, sub.Changed(), "cache publication")
247+ snapshot := monitor.Snapshot()
248+ if snapshot.Initialized || len(snapshot.Hosts) != 0 {
249+ t.Fatalf("cache-only snapshot = %+v, want uninitialized without hosts", snapshot)
250+ }
251+
252+ registration := testRegistration("user@arch", 1)
253+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{registration}}
254+ waitWake(t, sub.Changed(), "registry publication")
255+ snapshot = monitor.Snapshot()
256+ if !snapshot.Initialized || snapshot.Revision == 0 {
257+ t.Fatalf("registry snapshot = %+v, want initialized", snapshot)
258+ }
259+ host, ok := snapshot.Find("user@arch")
260+ if !ok {
261+ t.Fatalf("hosts = %+v, want user@arch", snapshot.Hosts)
262+ }
263+ if host.Availability != domain.RemoteAvailabilityUnknown {
264+ t.Fatalf("fresh host = %+v, want unknown availability", host)
265+ }
266+ // Discovery admits the first check in the same iteration that
267+ // publishes, so the host may already be checking here; admission is
268+ // proven by the observe job below.
269+ if !host.InventoryKnown || len(host.Sessions) != 1 || host.Sessions[0].Name != "cached" {
270+ t.Fatalf("host = %+v, want seeded cached inventory", host)
271+ }
272+ if host.Registration != registration {
273+ t.Fatalf("host registration = %+v, want %+v", host.Registration, registration)
274+ }
275+
276+ // The seeded host is due immediately: exactly one observation is admitted.
277+ observeJob := receiveJob(t, runtime.incoming, "catalog observe")
278+ if observeJob.Kind != ports.RemoteJobCatalogObserve || observeJob.Endpoint != "user@arch" {
279+ t.Fatalf("observe job = %+v, want catalog_observe for user@arch", observeJob)
280+ }
281+ if observeJob.Deadline != observeTimeout {
282+ t.Fatalf("observe deadline = %v, want %v", observeJob.Deadline, observeTimeout)
283+ }
284+}
285+
286+func TestMonitorCacheSeedingRules(t *testing.T) {
287+ clock := newFakeClock(time.Unix(1_000, 0))
288+ runtime := newFakeRuntime()
289+ monitor, _ := startMonitor(t, clock, runtime)
290+ sub := monitor.Subscribe()
291+ defer sub.Close()
292+
293+ receiveJob(t, runtime.incoming, "registry read")
294+ receiveJob(t, runtime.incoming, "cache load")
295+
296+ matching := testRegistration("user@arch", 1)
297+ other := testRegistration("user@mule", 2)
298+ runtime.outgoing <- ports.RemoteJobResult{
299+ Kind: ports.RemoteJobCacheLoad,
300+ Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "user@arch", FetchedAt: clock.Now(), Incarnation: [16]byte{9}, Sessions: []catalogue.RemoteCatalogSession{{Name: "stale-owner"}}}}, // mismatched incarnation
301+ }
302+ waitWake(t, sub.Changed(), "cache publication")
303+ runtime.outgoing <- ports.RemoteJobResult{
304+ Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{matching, other},
305+ }
306+ waitWake(t, sub.Changed(), "registry publication")
307+ // Drain the two admitted observations without answering.
308+ first := receiveJob(t, runtime.incoming, "first observe")
309+ second := receiveJob(t, runtime.incoming, "second observe")
310+ _ = first
311+ _ = second
312+
313+ snapshot := monitor.Snapshot()
314+ arch, ok := snapshot.Find("user@arch")
315+ if !ok || arch.InventoryKnown || len(arch.Sessions) != 0 {
316+ t.Fatalf("mismatched cache must not seed: %+v", arch)
317+ }
318+}
319+
320+// TestMonitorIdenticalRegistryPollPublishesNothing proves steady-state polls
321+// are silent: unchanged registrations with already-seeded cache produce no
322+// revision, while the seeded sessions were published exactly once.
323+func TestMonitorIdenticalRegistryPollPublishesNothing(t *testing.T) {
324+ clock := newFakeClock(time.Unix(1_000, 0))
325+ runtime := newFakeRuntime()
326+ monitor, _ := startMonitor(t, clock, runtime)
327+ sub := monitor.Subscribe()
328+ defer sub.Close()
329+
330+ receiveJob(t, runtime.incoming, "registry read")
331+ receiveJob(t, runtime.incoming, "cache load")
332+ registration := testRegistration("user@arch", 1)
333+ runtime.outgoing <- ports.RemoteJobResult{
334+ Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{registration},
335+ }
336+ waitWake(t, sub.Changed(), "registry publication")
337+ // Leave the admitted observation unanswered: no live inventory may
338+ // confirm and mask the cache seeding below.
339+ receiveObserve(t, runtime.incoming, "catalog observe")
340+
341+ runtime.outgoing <- ports.RemoteJobResult{
342+ Kind: ports.RemoteJobCacheLoad,
343+ Entries: []catalogue.RemoteCatalogCacheEntry{{Host: "user@arch", FetchedAt: clock.Now(),
344+ Incarnation: [16]byte{1}, Sessions: []catalogue.RemoteCatalogSession{{Name: "work"}}}},
345+ }
346+ waitWake(t, sub.Changed(), "cache publication")
347+ host, ok := monitor.Snapshot().Find("user@arch")
348+ if !ok || !host.InventoryKnown || len(host.Sessions) != 1 {
349+ t.Fatalf("cache must seed visible sessions: %+v", host)
350+ }
351+
352+ // An identical poll changes neither membership nor seeded state.
353+ runtime.outgoing <- ports.RemoteJobResult{
354+ Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{registration},
355+ }
356+ select {
357+ case <-sub.Changed():
358+ t.Fatal("identical registry poll published a revision")
359+ case <-time.After(150 * time.Millisecond):
360+ }
361+ host, _ = monitor.Snapshot().Find("user@arch")
362+ if len(host.Sessions) != 1 {
363+ t.Fatalf("seeded sessions must survive silent polls: %+v", host)
364+ }
365+}
366+
367+func TestMonitorObserveSuccessSchedulesHealthyCheck(t *testing.T) {
368+ clock := newFakeClock(time.Unix(1_000, 0))
369+ runtime := newFakeRuntime()
370+ monitor, _ := startMonitor(t, clock, runtime)
371+ sub := monitor.Subscribe()
372+ defer sub.Close()
373+
374+ receiveJob(t, runtime.incoming, "registry read")
375+ receiveJob(t, runtime.incoming, "cache load")
376+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad}
377+ waitWake(t, sub.Changed(), "cache publication")
378+ registration := testRegistration("user@arch", 1)
379+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{registration}}
380+ waitWake(t, sub.Changed(), "registry publication")
381+
382+ observe := receiveObserve(t, runtime.incoming, "catalog observe")
383+ runtime.outgoing <- ports.RemoteJobResult{
384+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "user@arch",
385+ Registration: registration, Generation: observe.Generation,
386+ Catalog: catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}}},
387+ }
388+ waitSnapshot(t, monitor, "success publication", func(snapshot ports.RemoteDirectorySnapshot) bool {
389+ host, ok := snapshot.Find("user@arch")
390+ return ok && host.Availability == domain.RemoteAvailabilityReachable && !host.Checking
391+ })
392+ host, _ := monitor.Snapshot().Find("user@arch")
393+ if !host.InventoryKnown || len(host.Sessions) != 1 {
394+ t.Fatalf("host = %+v, want confirmed inventory", host)
395+ }
396+ if host.ConsecutiveFailures != 0 {
397+ t.Fatalf("failures = %d, want 0", host.ConsecutiveFailures)
398+ }
399+
400+ // Healthy cadence is 15s ±10%: nothing due at 13s, due by 17s.
401+ clock.Advance(13 * time.Second)
402+ requireNoObserve(t, runtime.incoming, "early healthy recheck")
403+ clock.Advance(4 * time.Second)
404+ followUp := receiveObserve(t, runtime.incoming, "healthy recheck")
405+ if followUp.Generation == observe.Generation {
406+ t.Fatalf("recheck reused generation %d", followUp.Generation)
407+ }
408+}
409+
410+func TestMonitorRetryBackoffSequence(t *testing.T) {
411+ tests := []struct {
412+ name string
413+ failureKind domain.RemoteFailureKind
414+ availability domain.RemoteAvailability
415+ minDelay time.Duration
416+ maxDelay time.Duration
417+ }{
418+ {name: "transport", failureKind: domain.RemoteFailureTransport, availability: domain.RemoteAvailabilityUnreachable, minDelay: 4500 * time.Millisecond, maxDelay: 5500 * time.Millisecond},
419+ {name: "timeout maps to transport default", failureKind: domain.RemoteFailureNone, availability: domain.RemoteAvailabilityUnreachable, minDelay: 4500 * time.Millisecond, maxDelay: 5500 * time.Millisecond},
420+ {name: "authentication", failureKind: domain.RemoteFailureAuthentication, availability: domain.RemoteAvailabilityAuthFailed, minDelay: 4500 * time.Millisecond, maxDelay: 5500 * time.Millisecond},
421+ {name: "incompatible", failureKind: domain.RemoteFailureIncompatible, availability: domain.RemoteAvailabilityIncompatible, minDelay: 4500 * time.Millisecond, maxDelay: 5500 * time.Millisecond},
422+ {name: "invalid response", failureKind: domain.RemoteFailureInvalidResponse, availability: domain.RemoteAvailabilityInvalidResponse, minDelay: 4500 * time.Millisecond, maxDelay: 5500 * time.Millisecond},
423+ }
424+ for _, test := range tests {
425+ t.Run(test.name, func(t *testing.T) {
426+ clock := newFakeClock(time.Unix(1_000, 0))
427+ runtime := newFakeRuntime()
428+ monitor, _ := startMonitor(t, clock, runtime)
429+ sub := monitor.Subscribe()
430+ defer sub.Close()
431+
432+ receiveJob(t, runtime.incoming, "registry read")
433+ receiveJob(t, runtime.incoming, "cache load")
434+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad}
435+ waitWake(t, sub.Changed(), "cache publication")
436+ registration := testRegistration("user@arch", 1)
437+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{registration}}
438+ waitWake(t, sub.Changed(), "registry publication")
439+ observe := receiveObserve(t, runtime.incoming, "catalog observe")
440+ runtime.outgoing <- ports.RemoteJobResult{
441+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "user@arch",
442+ Registration: registration, Generation: observe.Generation,
443+ FailureKind: test.failureKind, Err: errMonitorTestFailure,
444+ }
445+ waitSnapshot(t, monitor, "failure publication", func(snapshot ports.RemoteDirectorySnapshot) bool {
446+ host, ok := snapshot.Find("user@arch")
447+ return ok && host.Availability == test.availability && host.ConsecutiveFailures == 1
448+ })
449+ host, _ := monitor.Snapshot().Find("user@arch")
450+ if host.ConsecutiveFailures != 1 || host.LastFailure.Kind != test.failureKind && !(test.failureKind == domain.RemoteFailureNone && host.LastFailure.Kind == domain.RemoteFailureTransport) {
451+ t.Fatalf("host = %+v, want one classified failure", host)
452+ }
453+ clock.Advance(test.minDelay - time.Second)
454+ requireNoObserve(t, runtime.incoming, "early retry")
455+ clock.Advance(2 * time.Second)
456+ receiveObserve(t, runtime.incoming, "retry")
457+ })
458+ }
459+}
460+
461+func TestMonitorRetryBackoffGrowsAndCaps(t *testing.T) {
462+ clock := newFakeClock(time.Unix(1_000, 0))
463+ runtime := newFakeRuntime()
464+ monitor, _ := startMonitor(t, clock, runtime)
465+ sub := monitor.Subscribe()
466+ defer sub.Close()
467+
468+ receiveJob(t, runtime.incoming, "registry read")
469+ receiveJob(t, runtime.incoming, "cache load")
470+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad}
471+ waitWake(t, sub.Changed(), "cache publication")
472+ registration := testRegistration("user@arch", 1)
473+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{registration}}
474+ waitWake(t, sub.Changed(), "registry publication")
475+
476+ // Expected pre-jitter backoffs: 5, 10, 20, 40, 60, 60. Jitter is ±10%,
477+ // so assert one window per attempt below and above the nominal value.
478+ windows := [][2]time.Duration{
479+ {4500 * time.Millisecond, 5500 * time.Millisecond},
480+ {9 * time.Second, 11 * time.Second},
481+ {18 * time.Second, 22 * time.Second},
482+ {36 * time.Second, 44 * time.Second},
483+ {54 * time.Second, 66 * time.Second},
484+ {54 * time.Second, 66 * time.Second},
485+ }
486+ for i, window := range windows {
487+ observe := receiveObserve(t, runtime.incoming, "catalog observe")
488+ runtime.outgoing <- ports.RemoteJobResult{
489+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "user@arch",
490+ Registration: registration, Generation: observe.Generation,
491+ FailureKind: domain.RemoteFailureTimeout, Err: errMonitorTestFailure,
492+ }
493+ waitSnapshot(t, monitor, "failure publication", func(snapshot ports.RemoteDirectorySnapshot) bool {
494+ host, ok := snapshot.Find("user@arch")
495+ return ok && host.ConsecutiveFailures == uint(i+1)
496+ })
497+ host, _ := monitor.Snapshot().Find("user@arch")
498+ // Prior inventory is retained with its age on failure.
499+ if host.InventoryKnown {
500+ t.Fatalf("attempt %d: failed check must not invent inventory", i)
501+ }
502+ clock.Advance(window[0] - time.Second)
503+ requireNoObserve(t, runtime.incoming, "early retry")
504+ clock.Advance(window[1] - window[0] + 2*time.Second)
505+ }
506+ // The last window leaves the next retry pending: it must arrive now.
507+ receiveObserve(t, runtime.incoming, "capped retry")
508+}
509+
510+func TestMonitorFencesReRegistration(t *testing.T) {
511+ clock := newFakeClock(time.Unix(1_000, 0))
512+ runtime := newFakeRuntime()
513+ monitor, _ := startMonitor(t, clock, runtime)
514+ sub := monitor.Subscribe()
515+ defer sub.Close()
516+
517+ receiveJob(t, runtime.incoming, "registry read")
518+ receiveJob(t, runtime.incoming, "cache load")
519+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad}
520+ waitWake(t, sub.Changed(), "cache publication")
521+ first := testRegistration("user@arch", 1)
522+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{first}}
523+ waitWake(t, sub.Changed(), "registry publication")
524+ observe := receiveObserve(t, runtime.incoming, "catalog observe")
525+
526+ // Re-registration with a new incarnation while the check is in flight.
527+ clock.Advance(registryPollInterval)
528+ poll := receiveJob(t, runtime.incoming, "registry poll")
529+ if poll.Kind != ports.RemoteJobRegistryRead {
530+ t.Fatalf("poll = %+v, want registry_read", poll)
531+ }
532+ second := testRegistration("user@arch", 2)
533+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{second}}
534+ // The fresh registration resets: no inventory, unknown reachability,
535+ // and not checking, since the outstanding check belongs to the
536+ // previous registration.
537+ snapshot := waitSnapshot(t, monitor, "re-registration publication", func(snapshot ports.RemoteDirectorySnapshot) bool {
538+ host, ok := snapshot.Find("user@arch")
539+ return ok && host.Registration == second
540+ })
541+ host, _ := snapshot.Find("user@arch")
542+ if host.Availability != domain.RemoteAvailabilityUnknown || host.Checking {
543+ t.Fatalf("re-registered host = %+v, want unknown without checking", host)
544+ }
545+
546+ // The late result bound to the old incarnation fences out: it retires
547+ // its credit, never applies inventory or reachability, and never
548+ // clears the fresh registration's observation.
549+ runtime.outgoing <- ports.RemoteJobResult{
550+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "user@arch",
551+ Registration: first, Generation: observe.Generation,
552+ Catalog: catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "ghost"}}},
553+ }
554+ fresh := receiveObserve(t, runtime.incoming, "fresh observe")
555+ if fresh.Registration != second {
556+ t.Fatalf("fresh observe = %+v, want registration %+v", fresh.Registration, second)
557+ }
558+ snapshot = waitSnapshot(t, monitor, "fresh observe admission", func(snapshot ports.RemoteDirectorySnapshot) bool {
559+ host, ok := snapshot.Find("user@arch")
560+ return ok && host.Checking
561+ })
562+ host, _ = snapshot.Find("user@arch")
563+ if host.Availability != domain.RemoteAvailabilityUnknown || len(host.Sessions) != 0 {
564+ t.Fatalf("fenced result applied: %+v", host)
565+ }
566+}
567+
568+// TestMonitorFencesRemoveReAddAcrossReads covers the ABA path simple
569+// replacement misses: the endpoint vanishes in one registry read (dropping
570+// its policy while its observation is outstanding), returns with a new
571+// incarnation in a later read, and both completions arrive in both orders.
572+// The old result must never clear the new registration's active check nor
573+// admit a second concurrent observation for the endpoint.
574+func TestMonitorFencesRemoveReAddAcrossReads(t *testing.T) {
575+ for _, order := range []string{"old-first", "new-first"} {
576+ t.Run(order, func(t *testing.T) {
577+ clock := newFakeClock(time.Unix(1_000, 0))
578+ runtime := newFakeRuntime()
579+ monitor, _ := startMonitor(t, clock, runtime)
580+ sub := monitor.Subscribe()
581+ defer sub.Close()
582+
583+ receiveJob(t, runtime.incoming, "registry read")
584+ receiveJob(t, runtime.incoming, "cache load")
585+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad}
586+ waitWake(t, sub.Changed(), "cache publication")
587+ first := testRegistration("user@arch", 1)
588+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{first}}
589+ waitWake(t, sub.Changed(), "registry publication")
590+ old := receiveObserve(t, runtime.incoming, "catalog observe")
591+
592+ // The endpoint vanishes while its check is outstanding, then
593+ // returns with a new incarnation. Generations restart at
594+ // one, so only the registration identity fences the old
595+ // result out.
596+ clock.Advance(registryPollInterval)
597+ receiveJob(t, runtime.incoming, "registry poll")
598+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead}
599+ waitSnapshot(t, monitor, "removal publication", func(snapshot ports.RemoteDirectorySnapshot) bool {
600+ _, ok := snapshot.Find("user@arch")
601+ return !ok
602+ })
603+ clock.Advance(registryPollInterval)
604+ receiveJob(t, runtime.incoming, "registry poll")
605+ second := testRegistration("user@arch", 2)
606+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{second}}
607+ waitSnapshot(t, monitor, "re-add publication", func(snapshot ports.RemoteDirectorySnapshot) bool {
608+ host, ok := snapshot.Find("user@arch")
609+ return ok && host.Registration == second
610+ })
611+ // While the old request is outstanding no fresh check is
612+ // admitted: exactly one observation per endpoint even
613+ // across removal and re-add.
614+ requireNoObserve(t, runtime.incoming, "concurrent observe across re-add")
615+ // Retire the old request first: it fences out silently and
616+ // leaves the new policy (unknown, not checking) untouched.
617+ oldRetire := ports.RemoteJobResult{
618+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "user@arch",
619+ Registration: first, Generation: old.Generation,
620+ Catalog: catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "ghost"}}},
621+ }
622+ runtime.outgoing <- oldRetire
623+ fresh := receiveObserve(t, runtime.incoming, "fresh observe")
624+ if fresh.Registration != second {
625+ t.Fatalf("fresh observe = %+v, want registration %+v", fresh.Registration, second)
626+ }
627+ oldResult := ports.RemoteJobResult{
628+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "user@arch",
629+ Registration: first, Generation: old.Generation,
630+ Catalog: catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "ghost"}}},
631+ }
632+ newResult := ports.RemoteJobResult{
633+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "user@arch",
634+ Registration: second, Generation: fresh.Generation,
635+ Catalog: catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: catalogue.RemoteCatalogSessionUp}}},
636+ }
637+ if order == "old-first" {
638+ // A stale duplicate while the fresh check is
639+ // outstanding must retire silently: no ghost, no
640+ // cleared observation, no third check admitted.
641+ runtime.outgoing <- oldResult
642+ requireNoObserve(t, runtime.incoming, "third observe after stale duplicate")
643+ runtime.outgoing <- newResult
644+ } else {
645+ runtime.outgoing <- newResult
646+ runtime.outgoing <- oldResult
647+ }
648+ snapshot := waitSnapshot(t, monitor, "applied observation", func(snapshot ports.RemoteDirectorySnapshot) bool {
649+ host, ok := snapshot.Find("user@arch")
650+ return ok && host.Availability == domain.RemoteAvailabilityReachable
651+ })
652+ host, _ := snapshot.Find("user@arch")
653+ if len(host.Sessions) != 1 || host.Sessions[0].Name != "work" {
654+ t.Fatalf("host = %+v, want applied new inventory without ghost", host)
655+ }
656+ // Exactly two observations were ever admitted for the
657+ // endpoint: the stale result retired silently instead of
658+ // clearing the new check and triggering a third. A short
659+ // advance stays inside the healthy cadence, so only
660+ // registry polls (ignored here) may arrive.
661+ clock.Advance(time.Second)
662+ requireNoObserve(t, runtime.incoming, "duplicate observe after fencing")
663+ })
664+ }
665+}
666+
667+func TestMonitorRegistryFailurePreservesHosts(t *testing.T) {
668+ clock := newFakeClock(time.Unix(1_000, 0))
669+ runtime := newFakeRuntime()
670+ monitor, _ := startMonitor(t, clock, runtime)
671+ sub := monitor.Subscribe()
672+ defer sub.Close()
673+
674+ receiveJob(t, runtime.incoming, "registry read")
675+ receiveJob(t, runtime.incoming, "cache load")
676+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad}
677+ waitWake(t, sub.Changed(), "cache publication")
678+ registration := testRegistration("user@arch", 1)
679+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{registration}}
680+ waitWake(t, sub.Changed(), "registry publication")
681+ observe := receiveObserve(t, runtime.incoming, "catalog observe")
682+ runtime.outgoing <- ports.RemoteJobResult{
683+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "user@arch",
684+ Registration: registration, Generation: observe.Generation,
685+ Catalog: catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "work"}}},
686+ }
687+ waitWake(t, sub.Changed(), "success publication")
688+ // Drain the admitted cache store.
689+ store := receiveJob(t, runtime.incoming, "cache store")
690+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheStore, Entries: store.Entries}
691+
692+ clock.Advance(registryPollInterval)
693+ receiveJob(t, runtime.incoming, "registry poll")
694+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Err: errMonitorTestFailure}
695+ waitWake(t, sub.Changed(), "registry failure publication")
696+ snapshot := monitor.Snapshot()
697+ if !snapshot.Initialized || snapshot.RegistryError == nil {
698+ t.Fatalf("snapshot = %+v, want initialized with registry error", snapshot)
699+ }
700+ host, ok := snapshot.Find("user@arch")
701+ if !ok || host.Availability != domain.RemoteAvailabilityReachable || len(host.Sessions) != 1 {
702+ t.Fatalf("registry failure must preserve known host: %+v", snapshot.Hosts)
703+ }
704+}
705+
706+func TestMonitorSingleObservationPerEndpoint(t *testing.T) {
707+ clock := newFakeClock(time.Unix(1_000, 0))
708+ runtime := newFakeRuntime()
709+ monitor, _ := startMonitor(t, clock, runtime)
710+
711+ receiveJob(t, runtime.incoming, "registry read")
712+ receiveJob(t, runtime.incoming, "cache load")
713+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad}
714+ registration := testRegistration("user@arch", 1)
715+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{registration}}
716+ receiveObserve(t, runtime.incoming, "catalog observe")
717+
718+ // Hints, polls and large clock advances never admit a second concurrent
719+ // check for the same endpoint.
720+ monitor.RequestReconcile("user@arch")
721+ monitor.RegistryChanged()
722+ clock.Advance(time.Hour)
723+ requireNoObserve(t, runtime.incoming, "duplicate concurrent observe")
724+}
725+
726+func TestMonitorHintPermitsOneFollowUp(t *testing.T) {
727+ clock := newFakeClock(time.Unix(1_000, 0))
728+ runtime := newFakeRuntime()
729+ monitor, _ := startMonitor(t, clock, runtime)
730+ sub := monitor.Subscribe()
731+ defer sub.Close()
732+
733+ receiveJob(t, runtime.incoming, "registry read")
734+ receiveJob(t, runtime.incoming, "cache load")
735+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad}
736+ waitWake(t, sub.Changed(), "cache publication")
737+ registration := testRegistration("user@arch", 1)
738+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{registration}}
739+ waitWake(t, sub.Changed(), "registry publication")
740+ observe := receiveObserve(t, runtime.incoming, "catalog observe")
741+
742+ // A verify hint during the in-flight check converts to exactly one
743+ // follow-up without forcing a registry read: the hinted endpoint is
744+ // re-checked as soon as its current check completes.
745+ monitor.RequestReconcile("user@arch")
746+ clock.Advance(time.Second)
747+ requireNoJob(t, runtime.incoming, "registry read forced by verify hint")
748+ runtime.outgoing <- ports.RemoteJobResult{
749+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "user@arch",
750+ Registration: registration, Generation: observe.Generation,
751+ Catalog: catalogue.RemoteCatalog{},
752+ }
753+ followUp := receiveObserve(t, runtime.incoming, "follow-up observe")
754+ if followUp.Generation == observe.Generation {
755+ t.Fatalf("follow-up reused generation %d", followUp.Generation)
756+ }
757+
758+ // No further follow-up without another hint: the host returns to the
759+ // healthy cadence after the follow-up completes.
760+ runtime.outgoing <- ports.RemoteJobResult{
761+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "user@arch",
762+ Registration: registration, Generation: followUp.Generation,
763+ Catalog: catalogue.RemoteCatalog{},
764+ }
765+ waitSnapshot(t, monitor, "follow-up publication", func(snapshot ports.RemoteDirectorySnapshot) bool {
766+ host, ok := snapshot.Find("user@arch")
767+ return ok && host.Availability == domain.RemoteAvailabilityReachable && !host.Checking
768+ })
769+ clock.Advance(13 * time.Second)
770+ requireNoObserve(t, runtime.incoming, "unhinted extra observe")
771+}
772+
773+// TestMonitorVerifyHintAdmitsIdleHost proves a verify hint checks an idle
774+// endpoint immediately without waiting for its cadence and without forcing
775+// a registry read.
776+func TestMonitorVerifyHintAdmitsIdleHost(t *testing.T) {
777+ clock := newFakeClock(time.Unix(1_000, 0))
778+ runtime := newFakeRuntime()
779+ monitor, _ := startMonitor(t, clock, runtime)
780+ sub := monitor.Subscribe()
781+ defer sub.Close()
782+
783+ receiveJob(t, runtime.incoming, "registry read")
784+ receiveJob(t, runtime.incoming, "cache load")
785+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad}
786+ waitWake(t, sub.Changed(), "cache publication")
787+ registration := testRegistration("user@arch", 1)
788+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{registration}}
789+ waitWake(t, sub.Changed(), "registry publication")
790+ observe := receiveObserve(t, runtime.incoming, "catalog observe")
791+ runtime.outgoing <- ports.RemoteJobResult{
792+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "user@arch",
793+ Registration: registration, Generation: observe.Generation,
794+ Catalog: catalogue.RemoteCatalog{},
795+ }
796+ waitSnapshot(t, monitor, "observe publication", func(snapshot ports.RemoteDirectorySnapshot) bool {
797+ host, ok := snapshot.Find("user@arch")
798+ return ok && !host.Checking
799+ })
800+
801+ // The host is idle with its next check 15s out. A verify hint admits
802+ // a check on the next pass; no registry read is forced.
803+ monitor.RequestReconcile("user@arch")
804+ hinted := receiveObserve(t, runtime.incoming, "hinted observe")
805+ if hinted.Registration != registration {
806+ t.Fatalf("hinted observe = %+v, want registration %+v", hinted.Registration, registration)
807+ }
808+ requireNoJob(t, runtime.incoming, "registry read forced by verify hint")
809+}
810+
811+// TestMonitorRegistryChangedExpeditesPoll proves a registry mutation hint
812+// expedites the next registry read without waiting for the poll cadence.
813+func TestMonitorRegistryChangedExpeditesPoll(t *testing.T) {
814+ clock := newFakeClock(time.Unix(1_000, 0))
815+ runtime := newFakeRuntime()
816+ monitor, _ := startMonitor(t, clock, runtime)
817+ sub := monitor.Subscribe()
818+ defer sub.Close()
819+
820+ receiveJob(t, runtime.incoming, "registry read")
821+ receiveJob(t, runtime.incoming, "cache load")
822+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad}
823+ waitWake(t, sub.Changed(), "cache publication")
824+ registration := testRegistration("user@arch", 1)
825+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{registration}}
826+ waitWake(t, sub.Changed(), "registry publication")
827+ receiveObserve(t, runtime.incoming, "catalog observe")
828+
829+ // One second later the periodic poll is not due, but the mutation
830+ // hint expedites it.
831+ clock.Advance(time.Second)
832+ monitor.RegistryChanged()
833+ poll := receiveJob(t, runtime.incoming, "expedited registry poll")
834+ if poll.Kind != ports.RemoteJobRegistryRead {
835+ t.Fatalf("poll = %+v, want registry_read", poll)
836+ }
837+}
838+
839+func TestMonitorCacheStoreSupersession(t *testing.T) {
840+ clock := newFakeClock(time.Unix(1_000, 0))
841+ runtime := newFakeRuntime()
842+ monitor, _ := startMonitor(t, clock, runtime)
843+ sub := monitor.Subscribe()
844+ defer sub.Close()
845+
846+ receiveJob(t, runtime.incoming, "registry read")
847+ receiveJob(t, runtime.incoming, "cache load")
848+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad}
849+ waitWake(t, sub.Changed(), "cache publication")
850+ registration := testRegistration("user@arch", 1)
851+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{registration}}
852+ waitWake(t, sub.Changed(), "registry publication")
853+
854+ observe := receiveObserve(t, runtime.incoming, "catalog observe")
855+ runtime.outgoing <- ports.RemoteJobResult{
856+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "user@arch",
857+ Registration: registration, Generation: observe.Generation,
858+ Catalog: catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "v1"}}},
859+ }
860+ waitWake(t, sub.Changed(), "success publication")
861+ firstStore := receiveJob(t, runtime.incoming, "cache store")
862+ if len(firstStore.Entries) != 1 || len(firstStore.Entries[0].Sessions) != 1 || firstStore.Entries[0].Sessions[0].Name != "v1" {
863+ t.Fatalf("first store = %+v, want v1 inventory", firstStore.Entries)
864+ }
865+
866+ // The writer stays blocked: a newer success supersedes the pending
867+ // snapshot instead of queueing behind it.
868+ clock.Advance(17 * time.Second)
869+ second := receiveObserve(t, runtime.incoming, "second observe")
870+ runtime.outgoing <- ports.RemoteJobResult{
871+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "user@arch",
872+ Registration: registration, Generation: second.Generation,
873+ Catalog: catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "v2"}}},
874+ }
875+ waitWake(t, sub.Changed(), "second success publication")
876+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheStore, Entries: firstStore.Entries}
877+ // The superseding write is admitted next; unrelated lane traffic such
878+ // as registry polls may arrive first on the shared job channel.
879+ var superseding ports.RemoteJob
880+ deadline := time.After(5 * time.Second)
881+ for superseding.Kind != ports.RemoteJobCacheStore {
882+ select {
883+ case superseding = <-runtime.incoming:
884+ case <-deadline:
885+ t.Fatal("timed out waiting for superseding store")
886+ }
887+ }
888+ if len(superseding.Entries) != 1 || len(superseding.Entries[0].Sessions) != 1 || superseding.Entries[0].Sessions[0].Name != "v2" {
889+ t.Fatalf("superseding store = %+v, want newest v2 inventory without loss", superseding.Entries)
890+ }
891+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheStore, Entries: superseding.Entries}
892+}
893+
894+func TestMonitorCooperativeCancel(t *testing.T) {
895+ clock := newFakeClock(time.Unix(1_000, 0))
896+ runtime := newFakeRuntime()
897+ monitor := NewMonitor(runtime, clock, nil)
898+ ctx, cancel := context.WithCancel(context.Background())
899+ done := make(chan error, 1)
900+ go func() { done <- monitor.Run(ctx) }()
901+ receiveJob(t, runtime.incoming, "registry read")
902+ cancel()
903+ select {
904+ case <-done:
905+ case <-time.After(5 * time.Second):
906+ t.Fatal("Run did not return after cancel")
907+ }
908+}
909+
910+// systemTestClock is a real-time ports.Clock for the bounded spin test:
911+// blocked lanes must park the loop however time advances.
912+type systemTestClock struct{}
913+
914+func (systemTestClock) Now() time.Time { return time.Now() }
915+
916+func (systemTestClock) NewTimer(d time.Duration) ports.Timer {
917+ return systemTestTimer{Timer: time.NewTimer(d)}
918+}
919+
920+type systemTestTimer struct {
921+ *time.Timer
922+}
923+
924+func (t systemTestTimer) C() <-chan time.Time { return t.Timer.C }
925+
926+// TestMonitorBlockedRegistryDoesNotSpin proves a blocked initial registry
927+// read cannot busy-spin the policy loop: with real time running and no
928+// completion ever arriving, only the single initial admission happens.
929+func TestMonitorBlockedRegistryDoesNotSpin(t *testing.T) {
930+ runtime := newFakeRuntime()
931+ monitor := NewMonitor(runtime, systemTestClock{}, nil)
932+ ctx, cancel := context.WithCancel(context.Background())
933+ defer cancel()
934+ done := make(chan error, 1)
935+ go func() { done <- monitor.Run(ctx) }()
936+ // The initial registry read and cache load are admitted once; the
937+ // blocked lanes never complete, so no further work can become
938+ // admittable and the loop must park instead of spinning.
939+ admissions := 0
940+ deadline := time.After(300 * time.Millisecond)
941+ collecting := true
942+ for collecting {
943+ select {
944+ case <-runtime.incoming:
945+ admissions++
946+ case <-deadline:
947+ collecting = false
948+ }
949+ }
950+ if admissions != 2 {
951+ t.Fatalf("admissions = %d, want exactly the initial registry read and cache load", admissions)
952+ }
953+}
954+
955+// TestMonitorInitialRegistryFailureRetriesBounded proves a failed first
956+// registry read publishes the error without hosts and retries on the
957+// bounded poll cadence instead of spinning.
958+func TestMonitorInitialRegistryFailureRetriesBounded(t *testing.T) {
959+ clock := newFakeClock(time.Unix(1_000, 0))
960+ runtime := newFakeRuntime()
961+ monitor, _ := startMonitor(t, clock, runtime)
962+ sub := monitor.Subscribe()
963+ defer sub.Close()
964+
965+ receiveJob(t, runtime.incoming, "registry read")
966+ receiveJob(t, runtime.incoming, "cache load")
967+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad}
968+ waitWake(t, sub.Changed(), "cache publication")
969+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Err: errMonitorTestFailure}
970+ snapshot := waitSnapshot(t, monitor, "registry failure publication", func(snapshot ports.RemoteDirectorySnapshot) bool {
971+ return snapshot.RegistryError != nil
972+ })
973+ if snapshot.Initialized || len(snapshot.Hosts) != 0 {
974+ t.Fatalf("failed initial read = %+v, want uninitialized without hosts", snapshot)
975+ }
976+ // No immediate retry while fake time is held: the failure backs off
977+ // to the poll cadence.
978+ clock.Advance(time.Second)
979+ requireNoJob(t, runtime.incoming, "immediate registry retry")
980+ clock.Advance(registryPollInterval)
981+ retry := receiveJob(t, runtime.incoming, "registry retry")
982+ if retry.Kind != ports.RemoteJobRegistryRead {
983+ t.Fatalf("retry = %+v, want registry_read", retry)
984+ }
985+}
986+
987+// TestMonitorCacheLoadFailureRetriesBounded proves a failed cache load
988+// retries on the bounded cadence instead of spinning or stalling the
989+// registry bootstrap.
990+func TestMonitorCacheLoadFailureRetriesBounded(t *testing.T) {
991+ clock := newFakeClock(time.Unix(1_000, 0))
992+ runtime := newFakeRuntime()
993+ monitor, _ := startMonitor(t, clock, runtime)
994+ sub := monitor.Subscribe()
995+ defer sub.Close()
996+
997+ receiveJob(t, runtime.incoming, "registry read")
998+ receiveJob(t, runtime.incoming, "cache load")
999+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad, Err: errMonitorTestFailure}
1000+ registration := testRegistration("user@arch", 1)
1001+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{registration}}
1002+ waitWake(t, sub.Changed(), "registry publication")
1003+ receiveObserve(t, runtime.incoming, "catalog observe")
1004+
1005+ // The failed load is retried on the poll cadence, not immediately.
1006+ clock.Advance(time.Second)
1007+ requireNoJob(t, runtime.incoming, "immediate cache retry")
1008+ clock.Advance(registryPollInterval)
1009+ // A periodic registry poll may win the admission race; the cache
1010+ // retry must still arrive on its bounded cadence.
1011+ deadline := time.After(5 * time.Second)
1012+ for {
1013+ select {
1014+ case job := <-runtime.incoming:
1015+ if job.Kind == ports.RemoteJobCacheLoad {
1016+ goto retried
1017+ }
1018+ case <-deadline:
1019+ t.Fatal("timed out waiting for cache retry")
1020+ }
1021+ }
1022+retried:
1023+}
1024+
1025+// TestMonitorObserveDispatchesOldestDueFirst proves pool fairness: with
1026+// every lane busy except one credit, the endpoint whose check is most
1027+// overdue wins over an alphabetically earlier endpoint due later.
1028+func TestMonitorObserveDispatchesOldestDueFirst(t *testing.T) {
1029+ clock := newFakeClock(time.Unix(1_000, 0))
1030+ runtime := newFakeRuntime()
1031+ monitor, _ := startMonitor(t, clock, runtime)
1032+ sub := monitor.Subscribe()
1033+ defer sub.Close()
1034+
1035+ receiveJob(t, runtime.incoming, "registry read")
1036+ receiveJob(t, runtime.incoming, "cache load")
1037+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobCacheLoad}
1038+ waitWake(t, sub.Changed(), "cache publication")
1039+ arch := testRegistration("arch", 1)
1040+ mule := testRegistration("mule", 2)
1041+ runtime.outgoing <- ports.RemoteJobResult{Kind: ports.RemoteJobRegistryRead, Registrations: []domain.RemoteRegistration{arch, mule}}
1042+ waitWake(t, sub.Changed(), "registry publication")
1043+ // Both due now: alphabetical tiebreak admits arch first.
1044+ first := receiveObserve(t, runtime.incoming, "first observe")
1045+ if first.Endpoint != "arch" {
1046+ t.Fatalf("first observe = %q, want arch", first.Endpoint)
1047+ }
1048+ second := receiveObserve(t, runtime.incoming, "second observe")
1049+ if second.Endpoint != "mule" {
1050+ t.Fatalf("second observe = %q, want mule", second.Endpoint)
1051+ }
1052+ // Arch succeeds (healthy cadence ~15s); mule fails (retry ~5s).
1053+ // Six seconds later only mule is due, even though arch sorts first.
1054+ runtime.outgoing <- ports.RemoteJobResult{
1055+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "arch",
1056+ Registration: arch, Generation: first.Generation,
1057+ Catalog: catalogue.RemoteCatalog{},
1058+ }
1059+ runtime.outgoing <- ports.RemoteJobResult{
1060+ Kind: ports.RemoteJobCatalogObserve, Endpoint: "mule",
1061+ Registration: mule, Generation: second.Generation,
1062+ FailureKind: domain.RemoteFailureTransport, Err: errMonitorTestFailure,
1063+ }
1064+ waitSnapshot(t, monitor, "observations", func(snapshot ports.RemoteDirectorySnapshot) bool {
1065+ archHost, ok := snapshot.Find("arch")
1066+ if !ok || archHost.Checking {
1067+ return false
1068+ }
1069+ muleHost, ok := snapshot.Find("mule")
1070+ return ok && !muleHost.Checking
1071+ })
1072+ clock.Advance(6 * time.Second)
1073+ next := receiveObserve(t, runtime.incoming, "oldest-due observe")
1074+ if next.Endpoint != "mule" {
1075+ t.Fatalf("next observe = %q, want mule (overdue retry beats alphabetical order)", next.Endpoint)
1076+ }
1077+}
1078+
1079+func TestRetryDelaySequence(t *testing.T) {
1080+ tests := []struct {
1081+ name string
1082+ failures uint
1083+ want time.Duration
1084+ }{
1085+ {name: "healthy", failures: 0, want: 15 * time.Second},
1086+ {name: "first retry", failures: 1, want: 5 * time.Second},
1087+ {name: "second retry", failures: 2, want: 10 * time.Second},
1088+ {name: "third retry", failures: 3, want: 20 * time.Second},
1089+ {name: "fourth retry", failures: 4, want: 40 * time.Second},
1090+ {name: "capped", failures: 5, want: 60 * time.Second},
1091+ {name: "stays capped", failures: 100, want: 60 * time.Second},
1092+ }
1093+ for _, test := range tests {
1094+ t.Run(test.name, func(t *testing.T) {
1095+ if got := retryDelay(test.failures); got != test.want {
1096+ t.Fatalf("retryDelay(%d) = %v, want %v", test.failures, got, test.want)
1097+ }
1098+ })
1099+ }
1100+}
1101+
1102+func TestJitteredIsDeterministicAndBounded(t *testing.T) {
1103+ for _, base := range []time.Duration{15 * time.Second, 5 * time.Second, 60 * time.Second} {
1104+ first := jittered(base, "user@arch", 3)
1105+ if second := jittered(base, "user@arch", 3); first != second {
1106+ t.Fatalf("jittered(%v) not deterministic: %v vs %v", base, first, second)
1107+ }
1108+ lower := base * 90 / 100
1109+ upper := base + base*10/100
1110+ if first < lower || first > upper {
1111+ t.Fatalf("jittered(%v) = %v, want within [%v, %v]", base, first, lower, upper)
1112+ }
1113+ }
1114+ if jittered(0, "user@arch", 1) != 0 {
1115+ t.Fatal("zero base must stay zero")
1116+ }
1117+}
A internal/usecase/remotes/state.go +304 -0
@@ -0,0 +1,304 @@
1+package remotes
2+
3+import (
4+ "hash/fnv"
5+ "sort"
6+ "time"
7+
8+ "github.com/bnema/vev/internal/domain"
9+ "github.com/bnema/vev/internal/ports"
10+ "github.com/bnema/vev/internal/protocol/catalogue"
11+)
12+
13+// Monitor cadence and bounds. All policy timing is private and fake-clock
14+// tested; wall-clock never enters policy decisions directly.
15+const (
16+ healthyCheckInterval = 15 * time.Second
17+ observeTimeout = 10 * time.Second
18+ registryPollInterval = 5 * time.Second
19+ inventoryExpiryAge = 30 * time.Second
20+ baseRetryDelay = 5 * time.Second
21+ maxRetryDelay = 60 * time.Second
22+
23+ // Admission credits bound in-flight work per lane: one registry
24+ // reader, one startup cache reader, one cache writer, four catalogue
25+ // observers. The result channel holds all seven possible completions.
26+ maxObserveCredits = 4
27+ maxInFlightJobs = 7
28+ // maxPendingVerifyHints bounds coalesced endpoint verification
29+ // requests; beyond it new hints drop and the periodic cadence
30+ // covers the endpoint.
31+ maxPendingVerifyHints = 256
32+)
33+
34+// hostPolicy is the loop-owned per-registration observation state for one
35+// endpoint. Only the service loop mutates it; snapshots expose copies.
36+type hostPolicy struct {
37+ registration domain.RemoteRegistration
38+ rank int
39+ availability domain.RemoteAvailability
40+ observing bool
41+ observeGeneration domain.RemoteGeneration
42+ followUp bool
43+ attempts uint64
44+ consecutiveFailures uint
45+ lastFailure domain.RemoteFailure
46+ lastAttempt time.Time
47+ lastSuccess time.Time
48+ nextDue time.Time
49+ expiryNotified bool
50+ sessions []catalogue.RemoteCatalogSession
51+ inventoryKnown bool
52+ inventoryConfirmed bool
53+ // cacheSeeded records that the held cache entry was already applied
54+ // to this registration: re-seeding identical content must not publish
55+ // a revision on every poll. Seeding is one-shot: the applied entry is
56+ // consumed from the pending cache, so a re-created policy after a
57+ // registry flap only recovers once a fresh cache payload arrives.
58+ cacheSeeded bool
59+}
60+
61+// inFlightRequest is the exact admitted request for one endpoint. It is
62+// owned independently of registration membership: removing or replacing a
63+// registration never deletes it, so a late completion can only retire its
64+// credit and never mutate a different registration's policy.
65+type inFlightRequest struct {
66+ registration domain.RemoteRegistration
67+ generation domain.RemoteGeneration
68+}
69+
70+// serviceState is the loop-owned monitor state: host policies, admission
71+// credits, pending cache/store payloads and publication bookkeeping.
72+type serviceState struct {
73+ hosts map[string]*hostPolicy
74+ inFlight map[string]inFlightRequest
75+ verifyHints map[string]struct{}
76+ observeCredits int
77+ registryCredit bool
78+ cacheCredit bool
79+ storeCredit bool
80+ registryDone bool
81+ registryInit bool
82+ registryError error
83+ lastRegistryRead time.Time
84+ registryExpedite bool
85+ expeditedRead bool
86+ cacheDone bool
87+ cacheRetryAt time.Time
88+ pendingCache []catalogue.RemoteCatalogCacheEntry
89+ pendingStore []catalogue.RemoteCatalogCacheEntry
90+ storePending bool
91+ storeActive bool
92+ storeRetryAt time.Time
93+ stored []catalogue.RemoteCatalogCacheEntry
94+ lastPersisted []catalogue.RemoteCatalogCacheEntry
95+ dirty bool
96+}
97+
98+func newServiceState() *serviceState {
99+ return &serviceState{
100+ hosts: make(map[string]*hostPolicy),
101+ inFlight: make(map[string]inFlightRequest),
102+ verifyHints: make(map[string]struct{}),
103+ observeCredits: maxObserveCredits,
104+ registryCredit: true,
105+ cacheCredit: true,
106+ storeCredit: true,
107+ }
108+}
109+
110+// retryDelay returns 5, 10, 20, 40, then 60 seconds capped for consecutive
111+// failures. Zero failures is the healthy cadence, not a retry.
112+func retryDelay(failures uint) time.Duration {
113+ if failures == 0 {
114+ return healthyCheckInterval
115+ }
116+ delay := baseRetryDelay
117+ for i := uint(1); i < failures; i++ {
118+ delay *= 2
119+ if delay >= maxRetryDelay {
120+ return maxRetryDelay
121+ }
122+ }
123+ if delay > maxRetryDelay {
124+ return maxRetryDelay
125+ }
126+ return delay
127+}
128+
129+// jittered spreads a base delay by ±10% deterministically per endpoint and
130+// attempt so healthy hosts do not synchronize and tests stay stable without
131+// randomness infrastructure.
132+func jittered(base time.Duration, endpoint string, attempt uint64) time.Duration {
133+ if base <= 0 {
134+ return base
135+ }
136+ h := fnv.New32a()
137+ h.Write([]byte(endpoint))
138+ var counter [8]byte
139+ for i := 0; i < 8; i++ {
140+ counter[i] = byte(attempt >> (8 * i))
141+ }
142+ h.Write(counter[:])
143+ spread := int64(h.Sum32() % 21)
144+ return base*90/100 + time.Duration(spread)*base/100
145+}
146+
147+// orderedEndpoints returns sorted known endpoints for fair deterministic
148+// dispatch across the observation pool.
149+func (s *serviceState) orderedEndpoints() []string {
150+ out := make([]string, 0, len(s.hosts))
151+ for endpoint := range s.hosts {
152+ out = append(out, endpoint)
153+ }
154+ sort.Strings(out)
155+ return out
156+}
157+
158+// nextWakeup returns the earliest time admittable work may exist. Work
159+// that cannot be admitted without a completion (no credits, outstanding
160+// requests, backoff not yet elapsed) never produces an immediate wakeup:
161+// completions, hints and cancellation wake the loop instead. This keeps a
162+// blocked lane from busy-spinning the policy loop while fake time is held
163+// fixed or a lane stays occupied.
164+func (s *serviceState) nextWakeup(now time.Time) time.Time {
165+ var next time.Time
166+ consider := func(t time.Time) {
167+ if t.IsZero() {
168+ return
169+ }
170+ if next.IsZero() || t.Before(next) {
171+ next = t
172+ }
173+ }
174+ if s.registryCredit && (s.registryExpedite || !s.registryDone || !now.Before(s.lastRegistryRead.Add(registryPollInterval))) {
175+ return now
176+ }
177+ if s.registryCredit && s.registryDone {
178+ consider(s.lastRegistryRead.Add(registryPollInterval))
179+ }
180+ if s.cacheCredit && !s.cacheDone && !now.Before(s.cacheRetryAt) {
181+ return now
182+ }
183+ if !s.cacheDone {
184+ consider(s.cacheRetryAt)
185+ }
186+ if s.storeCredit && !s.storeActive && s.storePending && !now.Before(s.storeRetryAt) {
187+ return now
188+ }
189+ for endpoint, host := range s.hosts {
190+ if _, outstanding := s.inFlight[endpoint]; outstanding || host.observing {
191+ continue
192+ }
193+ if _, hinted := s.verifyHints[endpoint]; hinted && s.observeCredits > 0 {
194+ return now
195+ }
196+ if s.observeCredits <= 0 {
197+ continue
198+ }
199+ if now.Before(host.nextDue) {
200+ consider(host.nextDue)
201+ } else {
202+ return now
203+ }
204+ }
205+ for _, host := range s.hosts {
206+ if host.inventoryConfirmed && !host.expiryNotified {
207+ consider(host.lastSuccess.Add(inventoryExpiryAge))
208+ }
209+ }
210+ if next.IsZero() || next.Before(now) {
211+ // Nothing admittable and no future work: idle fallback. Lane
212+ // completions, hints and cancellation still wake the loop;
213+ // the timer only bounds how long a missed wake could sleep.
214+ return now.Add(registryPollInterval)
215+ }
216+ return next
217+}
218+
219+// cacheEntriesEqual compares cache payloads by host identity, incarnation
220+// binding, fetch time and exact session content so unchanged inventories
221+// never stage duplicate writes.
222+func cacheEntriesEqual(a, b []catalogue.RemoteCatalogCacheEntry) bool {
223+ if (a == nil) != (b == nil) || len(a) != len(b) {
224+ return false
225+ }
226+ for i := range a {
227+ if a[i].Host != b[i].Host || a[i].FetchedAt != b[i].FetchedAt || a[i].Incarnation != b[i].Incarnation {
228+ return false
229+ }
230+ if len(a[i].Sessions) != len(b[i].Sessions) {
231+ return false
232+ }
233+ for j := range a[i].Sessions {
234+ if !catalogSessionEqual(a[i].Sessions[j], b[i].Sessions[j]) {
235+ return false
236+ }
237+ }
238+ }
239+ return true
240+}
241+
242+func catalogSessionEqual(a, b catalogue.RemoteCatalogSession) bool {
243+ if a.LifecycleID != b.LifecycleID || a.Name != b.Name || a.State != b.State ||
244+ a.Ephemeral != b.Ephemeral || a.Attached != b.Attached ||
245+ a.LastUsedSeq != b.LastUsedSeq || a.ActiveTabID != b.ActiveTabID || a.Reason != b.Reason {
246+ return false
247+ }
248+ if len(a.Tabs) != len(b.Tabs) {
249+ return false
250+ }
251+ for i := range a.Tabs {
252+ if a.Tabs[i] != b.Tabs[i] {
253+ return false
254+ }
255+ }
256+ return true
257+}
258+
259+// availabilityFor maps an observation failure kind to the published
260+// availability. The typed failure kind is preserved separately for notice
261+// policy; every kind retries at the capped cadence.
262+func availabilityFor(kind domain.RemoteFailureKind) domain.RemoteAvailability {
263+ switch kind {
264+ case domain.RemoteFailureAuthentication:
265+ return domain.RemoteAvailabilityAuthFailed
266+ case domain.RemoteFailureIncompatible:
267+ return domain.RemoteAvailabilityIncompatible
268+ case domain.RemoteFailureInvalidResponse:
269+ return domain.RemoteAvailabilityInvalidResponse
270+ default:
271+ return domain.RemoteAvailabilityUnreachable
272+ }
273+}
274+
275+// buildSnapshot renders the current publication: ordered hosts with cloned
276+// inventories, preserving last-known availability and sessions while a check
277+// is in flight.
278+func (s *serviceState) buildSnapshot(revision uint64, initialized bool) ports.RemoteDirectorySnapshot {
279+ snapshot := ports.RemoteDirectorySnapshot{
280+ Revision: revision,
281+ Initialized: initialized,
282+ RegistryError: s.registryError,
283+ Hosts: make([]ports.RemoteHostSnapshot, 0, len(s.hosts)),
284+ }
285+ for _, endpoint := range s.orderedEndpoints() {
286+ host := s.hosts[endpoint]
287+ snapshot.Hosts = append(snapshot.Hosts, ports.RemoteHostSnapshot{
288+ Endpoint: endpoint,
289+ DisplayOrigin: domain.RemoteDisplayOrigin(endpoint),
290+ Rank: host.rank,
291+ Availability: host.availability,
292+ Checking: host.observing,
293+ LastAttempt: host.lastAttempt,
294+ LastSuccess: host.lastSuccess,
295+ NextDue: host.nextDue,
296+ ConsecutiveFailures: host.consecutiveFailures,
297+ LastFailure: host.lastFailure,
298+ InventoryKnown: host.inventoryKnown,
299+ Registration: host.registration,
300+ Sessions: append([]catalogue.RemoteCatalogSession(nil), host.sessions...),
301+ })
302+ }
303+ return snapshot
304+}
M scripts/demo/Dockerfile +1 -1
@@ -1,5 +1,5 @@
11 # syntax=docker/dockerfile:1
2-FROM golang:1.26 AS build
2+FROM golang:1.27 AS build
33 WORKDIR /src
44 COPY go.mod go.sum ./
55 RUN go mod download

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