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 Unverified
B5690EEEBB952194The key is unknown
52 files · +6175 -1920
M boundary_test.go +6 -0
| @@ -71,6 +71,12 @@ func TestImportBoundaryNegativeFixtures(t *testing.T) { | |||
| 71 | 71 | }{ | |
| 72 | 72 | {"usecase accepts semantic protocol", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol", false, true}, | |
| 73 | 73 | {"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}, | |
| 74 | 80 | {"usecase rejects adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/ipc", false, false}, | |
| 75 | 81 | {"domain rejects ports", modulePath + "/internal/domain", modulePath + "/internal/ports", false, false}, | |
| 76 | 82 | {"pkg rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", false, false}, | |
M internal/adapters/remote/catalog.go +138 -6
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "fmt" | |
| 9 | 9 | "io" | |
| 10 | 10 | "log/slog" | |
| 11 | + | "os" | |
| 11 | 12 | "os/exec" | |
| 12 | 13 | "strings" | |
| 13 | 14 | "time" | |
| @@ -24,7 +25,8 @@ import ( | |||
| 24 | 25 | const ( | |
| 25 | 26 | maxCatalogBytes = 1 << 20 | |
| 26 | 27 | maxCatalogDiagnosticBytes = 4 << 10 | |
| 27 | - | catalogCommandTimeout = 30 * time.Second | |
| 28 | + | catalogCommandTimeout = 10 * time.Second | |
| 29 | + | catalogSSHConnectTimeout = 5 * time.Second | |
| 28 | 30 | catalogCommandWaitDelay = 500 * time.Millisecond | |
| 29 | 31 | ) | |
| 30 | 32 | ||
| @@ -36,6 +38,131 @@ var ( | |||
| 36 | 38 | errCatalogTooLarge = errors.New("remote catalog: output exceeds size limit") | |
| 37 | 39 | ) | |
| 38 | 40 | ||
| 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 | + | ||
| 39 | 166 | // CatalogClient fetches remote session catalogs over SSH. | |
| 40 | 167 | type CatalogClient struct { | |
| 41 | 168 | command func(ctx context.Context, name string, args ...string) *exec.Cmd | |
| @@ -57,10 +184,13 @@ func (c *CatalogClient) listTimeout() time.Duration { | |||
| 57 | 184 | return catalogCommandTimeout | |
| 58 | 185 | } | |
| 59 | 186 | ||
| 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. | |
| 64 | 194 | func (c *CatalogClient) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) { | |
| 65 | 195 | if err := ctx.Err(); err != nil { | |
| 66 | 196 | return catalogue.RemoteCatalog{}, err | |
| @@ -77,8 +207,10 @@ func (c *CatalogClient) List(ctx context.Context, target string) (catalogue.Remo | |||
| 77 | 207 | if command == nil { | |
| 78 | 208 | command = exec.CommandContext | |
| 79 | 209 | } | |
| 80 | - | spec := sshstdio.BuildCommandForRemoteCommand(target, "vev", "cmd", "remote-catalog", "--json") | |
| 210 | + | spec := sshstdio.BuildCommandForObservation(target, catalogSSHConnectTimeout, "vev", "cmd", "remote-catalog", "--json") | |
| 81 | 211 | 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. | |
| 82 | 214 | stdout := boundedBuffer{limit: maxCatalogBytes} | |
| 83 | 215 | stderr := boundedBuffer{limit: maxCatalogDiagnosticBytes} | |
| 84 | 216 | cmd.Stdout = &stdout | |
M internal/adapters/remote/catalog_cache.go +29 -6
| @@ -2,6 +2,7 @@ package remote | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "encoding/hex" | |
| 5 | 6 | "encoding/json" | |
| 6 | 7 | "errors" | |
| 7 | 8 | "fmt" | |
| @@ -17,9 +18,10 @@ import ( | |||
| 17 | 18 | ) | |
| 18 | 19 | ||
| 19 | 20 | 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 | |
| 23 | 25 | ) | |
| 24 | 26 | ||
| 25 | 27 | // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir. | |
| @@ -37,6 +39,7 @@ type catalogCacheVersion struct { | |||
| 37 | 39 | type catalogCacheHost struct { | |
| 38 | 40 | Target *string `json:"target"` | |
| 39 | 41 | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | |
| 42 | + | Incarnation *string `json:"incarnation,omitempty"` | |
| 40 | 43 | Sessions *[]catalogCacheSession `json:"sessions"` | |
| 41 | 44 | } | |
| 42 | 45 | ||
| @@ -109,8 +112,14 @@ func (c *fileCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { | |||
| 109 | 112 | } | |
| 110 | 113 | ||
| 111 | 114 | var file catalogCacheFile | |
| 115 | + | requireIncarnation := false | |
| 112 | 116 | switch *version.Version { | |
| 113 | 117 | case catalogCacheFileVersion: | |
| 118 | + | requireIncarnation = true | |
| 119 | + | if err := decodeCatalogCacheFile(raw, true, &file); err != nil { | |
| 120 | + | return nil, err | |
| 121 | + | } | |
| 122 | + | case catalogCacheLegacyFileVersion: | |
| 114 | 123 | if err := decodeCatalogCacheFile(raw, true, &file); err != nil { | |
| 115 | 124 | return nil, err | |
| 116 | 125 | } | |
| @@ -147,6 +156,17 @@ func (c *fileCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { | |||
| 147 | 156 | FetchedAt: time.Unix(0, *host.FetchedAtUnixNano), | |
| 148 | 157 | Sessions: make([]catalogue.RemoteCatalogSession, 0, len(*host.Sessions)), | |
| 149 | 158 | } | |
| 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. | |
| 150 | 170 | for _, session := range *host.Sessions { | |
| 151 | 171 | if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil { | |
| 152 | 172 | 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 | |||
| 238 | 258 | for _, entry := range normalized { | |
| 239 | 259 | target := entry.Host | |
| 240 | 260 | fetchedAt := entry.FetchedAt.UnixNano() | |
| 261 | + | incarnation := hex.EncodeToString(entry.Incarnation[:]) | |
| 241 | 262 | sessions := make([]catalogCacheSession, 0, len(entry.Sessions)) | |
| 242 | 263 | for _, session := range entry.Sessions { | |
| 243 | 264 | name := session.Name | |
| @@ -261,6 +282,7 @@ func (c *fileCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) er | |||
| 261 | 282 | hosts = append(hosts, catalogCacheHost{ | |
| 262 | 283 | Target: &target, | |
| 263 | 284 | FetchedAtUnixNano: &fetchedAt, | |
| 285 | + | Incarnation: &incarnation, | |
| 264 | 286 | Sessions: &sessions, | |
| 265 | 287 | }) | |
| 266 | 288 | } | |
| @@ -291,9 +313,10 @@ func normalizeCatalogCacheEntries(entries []catalogue.RemoteCatalogCacheEntry) ( | |||
| 291 | 313 | hosts[entry.Host] = struct{}{} | |
| 292 | 314 | ||
| 293 | 315 | 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)), | |
| 297 | 320 | } | |
| 298 | 321 | sessions := make(map[string]struct{}, len(entry.Sessions)) | |
| 299 | 322 | for _, session := range entry.Sessions { | |
M internal/adapters/remote/catalog_cache_test.go +45 -14
| @@ -29,8 +29,9 @@ func TestCatalogCacheStoreLoad(t *testing.T) { | |||
| 29 | 29 | fetchedAt := time.Unix(0, 1780000000000000000) | |
| 30 | 30 | entries := []catalogue.RemoteCatalogCacheEntry{ | |
| 31 | 31 | { | |
| 32 | - | Host: "zebra", | |
| 33 | - | FetchedAt: fetchedAt, | |
| 32 | + | Host: "zebra", | |
| 33 | + | FetchedAt: fetchedAt, | |
| 34 | + | Incarnation: [16]byte{9}, | |
| 34 | 35 | Sessions: []catalogue.RemoteCatalogSession{ | |
| 35 | 36 | { | |
| 36 | 37 | LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2", | |
| @@ -40,9 +41,10 @@ func TestCatalogCacheStoreLoad(t *testing.T) { | |||
| 40 | 41 | }, | |
| 41 | 42 | }, | |
| 42 | 43 | { | |
| 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{}, | |
| 46 | 48 | }, | |
| 47 | 49 | } | |
| 48 | 50 | ||
| @@ -63,8 +65,8 @@ func TestCatalogCacheStoreLoad(t *testing.T) { | |||
| 63 | 65 | if alpha < 0 || work < 0 || alpha >= work { | |
| 64 | 66 | t.Fatalf("stored sessions are not ordered: %s", raw) | |
| 65 | 67 | } | |
| 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) | |
| 68 | 70 | } | |
| 69 | 71 | if strings.Contains(string(raw), `"detail"`) || strings.Contains(string(raw), `"attention"`) { | |
| 70 | 72 | t.Fatalf("stored cache contains dynamic tab fields: %s", raw) | |
| @@ -75,13 +77,15 @@ func TestCatalogCacheStoreLoad(t *testing.T) { | |||
| 75 | 77 | } | |
| 76 | 78 | want := []catalogue.RemoteCatalogCacheEntry{ | |
| 77 | 79 | { | |
| 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{}, | |
| 81 | 84 | }, | |
| 82 | 85 | { | |
| 83 | - | Host: "zebra", | |
| 84 | - | FetchedAt: fetchedAt, | |
| 86 | + | Host: "zebra", | |
| 87 | + | FetchedAt: fetchedAt, | |
| 88 | + | Incarnation: [16]byte{9}, | |
| 85 | 89 | Sessions: []catalogue.RemoteCatalogSession{ | |
| 86 | 90 | {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true}, | |
| 87 | 91 | {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) { | |||
| 156 | 160 | } | |
| 157 | 161 | } | |
| 158 | 162 | ||
| 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 | + | ||
| 159 | 187 | func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { | |
| 160 | 188 | t.Parallel() | |
| 161 | 189 | ||
| @@ -166,7 +194,10 @@ func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { | |||
| 166 | 194 | {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)}, | |
| 167 | 195 | {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)}, | |
| 168 | 196 | {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":[]}]}`)}, | |
| 170 | 201 | {name: "missing hosts", raw: []byte(`{"version":3}`)}, | |
| 171 | 202 | {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)}, | |
| 172 | 203 | {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) { | |||
| 321 | 352 | } | |
| 322 | 353 | ||
| 323 | 354 | 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) | |
| 325 | 356 | } | |
M internal/adapters/remote/catalog_test.go +73 -5
| @@ -12,6 +12,7 @@ import ( | |||
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | 14 | "github.com/bnema/vev/internal/adapters/sshstdio" | |
| 15 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | 16 | "github.com/bnema/vev/internal/protocol" | |
| 16 | 17 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 17 | 18 | ) | |
| @@ -49,7 +50,7 @@ func TestRemoteCatalogClientCommandConstruction(t *testing.T) { | |||
| 49 | 50 | t.Fatalf("List() error = %v", err) | |
| 50 | 51 | } | |
| 51 | 52 | ||
| 52 | - | want := sshstdio.BuildCommandForRemoteCommand(tt.target, "vev", "cmd", "remote-catalog", "--json") | |
| 53 | + | want := sshstdio.BuildCommandForObservation(tt.target, catalogSSHConnectTimeout, "vev", "cmd", "remote-catalog", "--json") | |
| 53 | 54 | if gotPath != want.Path { | |
| 54 | 55 | t.Fatalf("Path = %q, want %q", gotPath, want.Path) | |
| 55 | 56 | } | |
| @@ -61,11 +62,24 @@ func TestRemoteCatalogClientCommandConstruction(t *testing.T) { | |||
| 61 | 62 | t.Fatalf("Args[%d] = %q, want %q (all %q)", i, gotArgs[i], want.Args[i], gotArgs) | |
| 62 | 63 | } | |
| 63 | 64 | } | |
| 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 | + | } | |
| 66 | 80 | } | |
| 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) | |
| 69 | 83 | } | |
| 70 | 84 | }) | |
| 71 | 85 | } | |
| @@ -273,6 +287,60 @@ func TestRemoteCatalogClientDecode(t *testing.T) { | |||
| 273 | 287 | } | |
| 274 | 288 | } | |
| 275 | 289 | ||
| 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 | + | ||
| 276 | 344 | func TestRemoteCatalogClientContextCanceled(t *testing.T) { | |
| 277 | 345 | t.Parallel() | |
| 278 | 346 | ctx, cancel := context.WithCancel(context.Background()) | |
M internal/adapters/remote/host_registry.go +271 -78
| @@ -1,13 +1,14 @@ | |||
| 1 | 1 | package remote | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "crypto/rand" | |
| 5 | + | "encoding/hex" | |
| 4 | 6 | "encoding/json" | |
| 5 | 7 | "errors" | |
| 6 | 8 | "fmt" | |
| 7 | 9 | "log/slog" | |
| 8 | 10 | "os" | |
| 9 | 11 | "path/filepath" | |
| 10 | - | "slices" | |
| 11 | 12 | "sort" | |
| 12 | 13 | "syscall" | |
| 13 | 14 | "unicode/utf8" | |
| @@ -18,27 +19,56 @@ import ( | |||
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | 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 | |
| 23 | 27 | ) | |
| 24 | 28 | ||
| 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 | + | ||
| 25 | 40 | // HostStorePath returns the canonical location of the remote host store in stateDir. | |
| 26 | 41 | func HostStorePath(stateDir string) string { return filepath.Join(stateDir, hostsFileName) } | |
| 27 | 42 | ||
| 28 | 43 | 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 { | |
| 29 | 59 | Version int `json:"version"` | |
| 30 | 60 | Pinned []string `json:"pinned"` | |
| 31 | 61 | Learned []string `json:"learned"` | |
| 32 | 62 | } | |
| 33 | 63 | ||
| 34 | - | type legacyHostsFile struct { | |
| 64 | + | type legacyListHostsFile struct { | |
| 35 | 65 | Version int `json:"version"` | |
| 36 | 66 | Hosts []string `json:"hosts"` | |
| 37 | 67 | } | |
| 38 | 68 | ||
| 39 | 69 | type hostState struct { | |
| 40 | - | pinned []string | |
| 41 | - | learned map[string]struct{} | |
| 70 | + | pinned []domain.RemoteRegistration | |
| 71 | + | learned map[string]domain.RemoteRegistration | |
| 42 | 72 | } | |
| 43 | 73 | ||
| 44 | 74 | type fileHostStore struct { | |
| @@ -52,14 +82,14 @@ func NewFileHostStore(path string) ports.RemoteHostStore { | |||
| 52 | 82 | ||
| 53 | 83 | var _ ports.RemoteHostStore = (*fileHostStore)(nil) | |
| 54 | 84 | ||
| 55 | - | func (s *fileHostStore) Hosts() (pinned, learned []string, err error) { | |
| 85 | + | func (s *fileHostStore) Hosts() (pinned, learned []domain.RemoteRegistration, err error) { | |
| 56 | 86 | err = s.withLock(func() error { | |
| 57 | - | state, loadErr := s.loadLocked() | |
| 87 | + | state, loadErr := s.loadMigratedLocked() | |
| 58 | 88 | if loadErr != nil { | |
| 59 | 89 | return loadErr | |
| 60 | 90 | } | |
| 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) | |
| 63 | 93 | return nil | |
| 64 | 94 | }) | |
| 65 | 95 | if err != nil { | |
| @@ -74,14 +104,25 @@ func (s *fileHostStore) AddPinned(target string) error { | |||
| 74 | 104 | return err | |
| 75 | 105 | } | |
| 76 | 106 | err := s.withLock(func() error { | |
| 77 | - | state, err := s.loadLocked() | |
| 107 | + | state, err := s.loadMigratedLocked() | |
| 78 | 108 | if err != nil { | |
| 79 | 109 | return err | |
| 80 | 110 | } | |
| 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) | |
| 83 | 125 | } | |
| 84 | - | state.pinned = append(state.pinned, target) | |
| 85 | 126 | return s.saveLocked(state) | |
| 86 | 127 | }) | |
| 87 | 128 | if err != nil { | |
| @@ -95,11 +136,11 @@ func (s *fileHostStore) RemovePinned(target string) error { | |||
| 95 | 136 | return err | |
| 96 | 137 | } | |
| 97 | 138 | err := s.withLock(func() error { | |
| 98 | - | state, err := s.loadLocked() | |
| 139 | + | state, err := s.loadMigratedLocked() | |
| 99 | 140 | if err != nil { | |
| 100 | 141 | return err | |
| 101 | 142 | } | |
| 102 | - | next, changed := removeTarget(state.pinned, target) | |
| 143 | + | next, changed := removeRegistration(state.pinned, target) | |
| 103 | 144 | if !changed { | |
| 104 | 145 | return nil | |
| 105 | 146 | } | |
| @@ -117,14 +158,23 @@ func (s *fileHostStore) Remember(target string) error { | |||
| 117 | 158 | return err | |
| 118 | 159 | } | |
| 119 | 160 | err := s.withLock(func() error { | |
| 120 | - | state, err := s.loadLocked() | |
| 161 | + | state, err := s.loadMigratedLocked() | |
| 121 | 162 | if err != nil { | |
| 122 | 163 | return err | |
| 123 | 164 | } | |
| 124 | 165 | if _, ok := state.learned[target]; ok { | |
| 125 | 166 | return nil | |
| 126 | 167 | } | |
| 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 | + | } | |
| 128 | 178 | return s.saveLocked(state) | |
| 129 | 179 | }) | |
| 130 | 180 | if err != nil { | |
| @@ -138,7 +188,7 @@ func (s *fileHostStore) Forget(target string) error { | |||
| 138 | 188 | return err | |
| 139 | 189 | } | |
| 140 | 190 | err := s.withLock(func() error { | |
| 141 | - | state, err := s.loadLocked() | |
| 191 | + | state, err := s.loadMigratedLocked() | |
| 142 | 192 | if err != nil { | |
| 143 | 193 | return err | |
| 144 | 194 | } | |
| @@ -159,15 +209,17 @@ func (s *fileHostStore) Remove(target string) (deleted bool, err error) { | |||
| 159 | 209 | return false, err | |
| 160 | 210 | } | |
| 161 | 211 | err = s.withLock(func() error { | |
| 162 | - | state, err := s.loadLocked() | |
| 212 | + | state, err := s.loadMigratedLocked() | |
| 163 | 213 | if err != nil { | |
| 164 | 214 | return err | |
| 165 | 215 | } | |
| 166 | - | nextPinned, pinnedChanged := removeTarget(state.pinned, target) | |
| 216 | + | nextPinned, pinnedChanged := removeRegistration(state.pinned, target) | |
| 167 | 217 | _, learnedPresent := state.learned[target] | |
| 168 | 218 | if !pinnedChanged && !learnedPresent { | |
| 169 | 219 | return nil | |
| 170 | 220 | } | |
| 221 | + | // Complete removal drops the incarnation: a later re-add creates | |
| 222 | + | // a new registration even though the endpoint string matches. | |
| 171 | 223 | state.pinned = nextPinned | |
| 172 | 224 | delete(state.learned, target) | |
| 173 | 225 | if err := s.saveLocked(state); err != nil { | |
| @@ -202,12 +254,17 @@ func (s *fileHostStore) withLock(fn func() error) error { | |||
| 202 | 254 | return fn() | |
| 203 | 255 | } | |
| 204 | 256 | ||
| 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) { | |
| 206 | 263 | raw, err := os.ReadFile(s.path) | |
| 207 | 264 | if errors.Is(err, os.ErrNotExist) { | |
| 208 | 265 | 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), | |
| 211 | 268 | }, nil | |
| 212 | 269 | } | |
| 213 | 270 | if err != nil { | |
| @@ -224,52 +281,152 @@ func (s *fileHostStore) loadLocked() (hostState, error) { | |||
| 224 | 281 | } | |
| 225 | 282 | ||
| 226 | 283 | 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 | |
| 229 | 340 | if err := json.Unmarshal(raw, &legacy); err != nil { | |
| 230 | 341 | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err) | |
| 231 | 342 | } | |
| 232 | 343 | if legacy.Hosts == nil { | |
| 233 | 344 | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing hosts") | |
| 234 | 345 | } | |
| 235 | - | learned, err := validateLearnedHosts(legacy.Hosts) | |
| 346 | + | learned, err := migrateTargetsToRecords(legacy.Hosts, nil) | |
| 236 | 347 | if err != nil { | |
| 237 | 348 | return hostState{}, err | |
| 238 | 349 | } | |
| 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) | |
| 247 | 386 | } | |
| 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) | |
| 250 | 389 | } | |
| 251 | - | pinned, err := validatePinnedHosts(decoded.Pinned) | |
| 390 | + | seen[host] = struct{}{} | |
| 391 | + | record, err := newRemoteRegistration(host) | |
| 252 | 392 | if err != nil { | |
| 253 | - | return hostState{}, err | |
| 393 | + | return nil, err | |
| 254 | 394 | } | |
| 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) | |
| 256 | 414 | if err != nil { | |
| 257 | - | return hostState{}, err | |
| 415 | + | return nil, err | |
| 258 | 416 | } | |
| 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 | |
| 262 | 418 | } | |
| 419 | + | return learned, nil | |
| 263 | 420 | } | |
| 264 | 421 | ||
| 265 | 422 | 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)) | |
| 269 | 426 | } | |
| 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)) | |
| 273 | 430 | } | |
| 274 | 431 | payload, err := json.Marshal(hostsFile{ | |
| 275 | 432 | Version: hostsFileVersion, | |
| @@ -282,6 +439,31 @@ func (s *fileHostStore) saveLocked(state hostState) error { | |||
| 282 | 439 | return atomicReplacePrivateFile(s.path, append(payload, '\n')) | |
| 283 | 440 | } | |
| 284 | 441 | ||
| 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 | + | ||
| 285 | 467 | func atomicReplacePrivateFile(path string, payload []byte) error { | |
| 286 | 468 | dir := filepath.Dir(path) | |
| 287 | 469 | if err := safedir.EnsurePrivate(dir); err != nil { | |
| @@ -318,32 +500,34 @@ func atomicReplacePrivateFile(path string, payload []byte) error { | |||
| 318 | 500 | return syncDir(dir) | |
| 319 | 501 | } | |
| 320 | 502 | ||
| 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 | |
| 327 | 510 | } | |
| 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) | |
| 330 | 513 | } | |
| 331 | - | seen[host] = struct{}{} | |
| 332 | - | pinned = append(pinned, host) | |
| 514 | + | seen[registration.Endpoint] = struct{}{} | |
| 515 | + | pinned = append(pinned, registration) | |
| 333 | 516 | } | |
| 334 | 517 | return pinned, nil | |
| 335 | 518 | } | |
| 336 | 519 | ||
| 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 | |
| 342 | 526 | } | |
| 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) | |
| 345 | 529 | } | |
| 346 | - | learned[host] = struct{}{} | |
| 530 | + | learned[registration.Endpoint] = registration | |
| 347 | 531 | } | |
| 348 | 532 | return learned, nil | |
| 349 | 533 | } | |
| @@ -355,25 +539,34 @@ func validateHostTarget(target string) error { | |||
| 355 | 539 | return domain.ValidateRemoteHostTarget(target) | |
| 356 | 540 | } | |
| 357 | 541 | ||
| 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)) | |
| 360 | 553 | changed := false | |
| 361 | - | for _, host := range hosts { | |
| 362 | - | if host == target { | |
| 554 | + | for _, record := range registrations { | |
| 555 | + | if record.Endpoint == target { | |
| 363 | 556 | changed = true | |
| 364 | 557 | continue | |
| 365 | 558 | } | |
| 366 | - | out = append(out, host) | |
| 559 | + | out = append(out, record) | |
| 367 | 560 | } | |
| 368 | 561 | return out, changed | |
| 369 | 562 | } | |
| 370 | 563 | ||
| 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) | |
| 375 | 568 | } | |
| 376 | - | sort.Strings(out) | |
| 569 | + | sort.Slice(out, func(i, j int) bool { return out[i].Endpoint < out[j].Endpoint }) | |
| 377 | 570 | return out | |
| 378 | 571 | } | |
| 379 | 572 | ||
M internal/adapters/remote/host_registry_test.go +269 -37
| @@ -11,6 +11,8 @@ import ( | |||
| 11 | 11 | "strings" | |
| 12 | 12 | "sync" | |
| 13 | 13 | "testing" | |
| 14 | + | ||
| 15 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 18 | func TestHostStorePath(t *testing.T) { | |
| @@ -21,6 +23,23 @@ func TestHostStorePath(t *testing.T) { | |||
| 21 | 23 | } | |
| 22 | 24 | } | |
| 23 | 25 | ||
| 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 | + | ||
| 24 | 43 | func TestHostStoreCrossProcessHelper(t *testing.T) { | |
| 25 | 44 | if os.Getenv("VEV_HOST_STORE_HELPER") != "1" { | |
| 26 | 45 | t.Skip("helper process") | |
| @@ -84,11 +103,28 @@ func TestHostStoreCrossProcessConcurrentRemember(t *testing.T) { | |||
| 84 | 103 | } | |
| 85 | 104 | want := append(append([]string{}, left...), right...) | |
| 86 | 105 | sort.Strings(want) | |
| 87 | - | if !slices.Equal(learned, want) { | |
| 106 | + | if !slices.Equal(registrationEndpoints(learned), want) { | |
| 88 | 107 | t.Fatalf("learned len=%d want %d; missing merges under cross-process writers", len(learned), len(want)) | |
| 89 | 108 | } | |
| 90 | 109 | } | |
| 91 | 110 | ||
| 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 | + | ||
| 92 | 128 | func TestHostStore(t *testing.T) { | |
| 93 | 129 | t.Parallel() | |
| 94 | 130 | ||
| @@ -146,12 +182,14 @@ func TestHostStore(t *testing.T) { | |||
| 146 | 182 | } | |
| 147 | 183 | wantPinned := []string{"zebra", "arch"} | |
| 148 | 184 | wantLearned := []string{"beta", "mule"} | |
| 149 | - | if !slices.Equal(pinned, wantPinned) { | |
| 185 | + | if !slices.Equal(registrationEndpoints(pinned), wantPinned) { | |
| 150 | 186 | t.Fatalf("pinned = %v, want %v", pinned, wantPinned) | |
| 151 | 187 | } | |
| 152 | - | if !slices.Equal(learned, wantLearned) { | |
| 188 | + | if !slices.Equal(registrationEndpoints(learned), wantLearned) { | |
| 153 | 189 | t.Fatalf("learned = %v, want %v", learned, wantLearned) | |
| 154 | 190 | } | |
| 191 | + | requireValidRegistrations(t, pinned) | |
| 192 | + | requireValidRegistrations(t, learned) | |
| 155 | 193 | ||
| 156 | 194 | info, err := os.Stat(path) | |
| 157 | 195 | if err != nil { | |
| @@ -176,15 +214,31 @@ func TestHostStore(t *testing.T) { | |||
| 176 | 214 | if err := json.Unmarshal(raw, &decoded); err != nil { | |
| 177 | 215 | t.Fatalf("decode store: %v", err) | |
| 178 | 216 | } | |
| 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) | |
| 181 | 234 | } | |
| 182 | - | if !slices.Equal(decoded.Pinned, wantPinned) { | |
| 235 | + | if !slices.Equal(registrationEndpoints(stored[:len(decoded.Pinned)]), wantPinned) { | |
| 183 | 236 | t.Fatalf("stored pinned = %v, want %v", decoded.Pinned, wantPinned) | |
| 184 | 237 | } | |
| 185 | - | if !slices.Equal(decoded.Learned, wantLearned) { | |
| 238 | + | if !slices.Equal(registrationEndpoints(stored[len(decoded.Pinned):]), wantLearned) { | |
| 186 | 239 | t.Fatalf("stored learned = %v, want %v", decoded.Learned, wantLearned) | |
| 187 | 240 | } | |
| 241 | + | requireValidRegistrations(t, stored) | |
| 188 | 242 | }) | |
| 189 | 243 | ||
| 190 | 244 | t.Run("overlap allowed across pinned and learned", func(t *testing.T) { | |
| @@ -202,12 +256,17 @@ func TestHostStore(t *testing.T) { | |||
| 202 | 256 | if err != nil { | |
| 203 | 257 | t.Fatalf("Hosts() error = %v", err) | |
| 204 | 258 | } | |
| 205 | - | if !slices.Equal(pinned, []string{"arch"}) { | |
| 259 | + | if !slices.Equal(registrationEndpoints(pinned), []string{"arch"}) { | |
| 206 | 260 | t.Fatalf("pinned = %v, want [arch]", pinned) | |
| 207 | 261 | } | |
| 208 | - | if !slices.Equal(learned, []string{"arch"}) { | |
| 262 | + | if !slices.Equal(registrationEndpoints(learned), []string{"arch"}) { | |
| 209 | 263 | t.Fatalf("learned = %v, want [arch]", learned) | |
| 210 | 264 | } | |
| 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 | + | } | |
| 211 | 270 | }) | |
| 212 | 271 | ||
| 213 | 272 | t.Run("idempotent mutations and remove from both sources", func(t *testing.T) { | |
| @@ -255,7 +314,7 @@ func TestHostStore(t *testing.T) { | |||
| 255 | 314 | if len(pinned) != 0 { | |
| 256 | 315 | t.Fatalf("pinned after Remove(arch) = %v, want empty", pinned) | |
| 257 | 316 | } | |
| 258 | - | if !slices.Equal(learned, []string{"mule"}) { | |
| 317 | + | if !slices.Equal(registrationEndpoints(learned), []string{"mule"}) { | |
| 259 | 318 | t.Fatalf("learned after Remove(arch) = %v, want [mule]", learned) | |
| 260 | 319 | } | |
| 261 | 320 | ||
| @@ -303,7 +362,7 @@ func TestHostStore(t *testing.T) { | |||
| 303 | 362 | } | |
| 304 | 363 | }) | |
| 305 | 364 | ||
| 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) { | |
| 307 | 366 | t.Parallel() | |
| 308 | 367 | cases := []struct { | |
| 309 | 368 | name string | |
| @@ -324,21 +383,13 @@ func TestHostStore(t *testing.T) { | |||
| 324 | 383 | if err != nil { | |
| 325 | 384 | t.Fatalf("Hosts() error = %v", err) | |
| 326 | 385 | } | |
| 327 | - | if len(pinned) != 0 || !slices.Equal(learned, tc.wantLearned) { | |
| 386 | + | if len(pinned) != 0 || !slices.Equal(registrationEndpoints(learned), tc.wantLearned) { | |
| 328 | 387 | t.Fatalf("Hosts() = pinned %v learned %v, want pinned empty learned %v", pinned, learned, tc.wantLearned) | |
| 329 | 388 | } | |
| 389 | + | requireValidRegistrations(t, learned) | |
| 390 | + | // The read migrated: the file is now version 3 and the | |
| 391 | + | // pre-migration bytes are preserved privately. | |
| 330 | 392 | 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) | |
| 342 | 393 | if err != nil { | |
| 343 | 394 | t.Fatalf("read migrated store: %v", err) | |
| 344 | 395 | } | |
| @@ -349,13 +400,163 @@ func TestHostStore(t *testing.T) { | |||
| 349 | 400 | if decoded.Version != hostsFileVersion { | |
| 350 | 401 | t.Fatalf("migrated version = %d, want %d", decoded.Version, hostsFileVersion) | |
| 351 | 402 | } | |
| 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 | + | } | |
| 354 | 429 | } | |
| 355 | 430 | }) | |
| 356 | 431 | } | |
| 357 | 432 | }) | |
| 358 | 433 | ||
| 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 | + | ||
| 359 | 560 | t.Run("schema failures fail closed and preserve file", func(t *testing.T) { | |
| 360 | 561 | t.Parallel() | |
| 361 | 562 | cases := []struct { | |
| @@ -363,7 +564,7 @@ func TestHostStore(t *testing.T) { | |||
| 363 | 564 | raw string | |
| 364 | 565 | wantError string | |
| 365 | 566 | }{ | |
| 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"}, | |
| 367 | 568 | {name: "null pinned", raw: `{"version":2,"pinned":null,"learned":[]}`, wantError: "malformed hosts file"}, | |
| 368 | 569 | {name: "null learned", raw: `{"version":2,"pinned":[],"learned":null}`, wantError: "malformed hosts file"}, | |
| 369 | 570 | {name: "duplicate pinned", raw: `{"version":2,"pinned":["arch","arch"],"learned":[]}`, wantError: "malformed hosts file"}, | |
| @@ -375,6 +576,13 @@ func TestHostStore(t *testing.T) { | |||
| 375 | 576 | {name: "legacy duplicate hosts", raw: `{"version":1,"hosts":["arch","arch"]}`, wantError: "malformed hosts file: duplicate learned host"}, | |
| 376 | 577 | {name: "legacy invalid target", raw: `{"version":1,"hosts":[" arch"]}`, wantError: "malformed hosts file"}, | |
| 377 | 578 | {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"}, | |
| 378 | 586 | } | |
| 379 | 587 | for _, tc := range cases { | |
| 380 | 588 | t.Run(tc.name, func(t *testing.T) { | |
| @@ -402,28 +610,30 @@ func TestHostStore(t *testing.T) { | |||
| 402 | 610 | } | |
| 403 | 611 | }) | |
| 404 | 612 | ||
| 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) { | |
| 406 | 614 | t.Parallel() | |
| 407 | 615 | 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)) | |
| 410 | 618 | ||
| 411 | 619 | store := NewFileHostStore(path) | |
| 412 | 620 | pinned, learned, err := store.Hosts() | |
| 413 | 621 | if err != nil { | |
| 414 | 622 | t.Fatalf("Hosts() error = %v", err) | |
| 415 | 623 | } | |
| 416 | - | if !slices.Equal(pinned, []string{"zebra", "arch"}) { | |
| 624 | + | if !slices.Equal(registrationEndpoints(pinned), []string{"zebra", "arch"}) { | |
| 417 | 625 | t.Fatalf("pinned = %v, want [zebra arch]", pinned) | |
| 418 | 626 | } | |
| 419 | - | if !slices.Equal(learned, []string{"beta", "mule"}) { | |
| 627 | + | if !slices.Equal(registrationEndpoints(learned), []string{"beta", "mule"}) { | |
| 420 | 628 | t.Fatalf("learned = %v, want [beta mule]", learned) | |
| 421 | 629 | } | |
| 630 | + | // Identity survives the read; incarnations are stable. | |
| 631 | + | beforePinned, beforeLearned := pinned, learned | |
| 422 | 632 | raw, err := os.ReadFile(path) | |
| 423 | 633 | if err != nil { | |
| 424 | 634 | t.Fatalf("read store: %v", err) | |
| 425 | 635 | } | |
| 426 | - | if string(raw) != string(unsorted) { | |
| 636 | + | if string(raw) != seeded { | |
| 427 | 637 | t.Fatalf("read-only Hosts rewrote file: got %q", raw) | |
| 428 | 638 | } | |
| 429 | 639 | ||
| @@ -438,12 +648,34 @@ func TestHostStore(t *testing.T) { | |||
| 438 | 648 | if err := json.Unmarshal(raw, &decoded); err != nil { | |
| 439 | 649 | t.Fatalf("decode: %v", err) | |
| 440 | 650 | } | |
| 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"}) { | |
| 442 | 660 | t.Fatalf("pinned after mutation = %v", decoded.Pinned) | |
| 443 | 661 | } | |
| 444 | - | if !slices.Equal(decoded.Learned, []string{"beta", "gamma", "mule"}) { | |
| 662 | + | if !slices.Equal(registrationEndpoints(sortedRegistrations(afterLearned)), []string{"beta", "gamma", "mule"}) { | |
| 445 | 663 | t.Fatalf("learned after mutation = %v", decoded.Learned) | |
| 446 | 664 | } | |
| 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 | + | } | |
| 447 | 679 | }) | |
| 448 | 680 | ||
| 449 | 681 | t.Run("rejects invalid targets", func(t *testing.T) { | |
| @@ -650,15 +882,15 @@ func TestHostStore(t *testing.T) { | |||
| 650 | 882 | } | |
| 651 | 883 | wantLearned := append([]string{}, left...) | |
| 652 | 884 | sort.Strings(wantLearned) | |
| 653 | - | if !slices.Equal(learned, wantLearned) { | |
| 885 | + | if !slices.Equal(registrationEndpoints(learned), wantLearned) { | |
| 654 | 886 | t.Fatalf("learned len=%d want %d; missing merges under dual writers", len(learned), len(wantLearned)) | |
| 655 | 887 | } | |
| 656 | 888 | if len(pinned) != len(right) { | |
| 657 | 889 | t.Fatalf("pinned len=%d want %d", len(pinned), len(right)) | |
| 658 | 890 | } | |
| 659 | 891 | 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{}{} | |
| 662 | 894 | } | |
| 663 | 895 | for _, host := range right { | |
| 664 | 896 | 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 | |||
| 526 | 526 | return CommandSpec{Path: "ssh", Args: args} | |
| 527 | 527 | } | |
| 528 | 528 | ||
| 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 | + | ||
| 529 | 557 | // BuildCommandForRemoteLaunch prefixes an explicit complete environment and | |
| 530 | 558 | // absolute executable to a hidden remote mode. Each value remains one quoted | |
| 531 | 559 | // 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 ( | |||
| 7 | 7 | "errors" | |
| 8 | 8 | "io" | |
| 9 | 9 | "log/slog" | |
| 10 | + | "os" | |
| 10 | 11 | "os/exec" | |
| 11 | 12 | "strings" | |
| 12 | 13 | "sync" | |
| @@ -109,6 +110,82 @@ func TestBuildCommandForRemoteCommandQuotesEveryWord(t *testing.T) { | |||
| 109 | 110 | } | |
| 110 | 111 | } | |
| 111 | 112 | ||
| 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 | + | ||
| 112 | 189 | func TestCloseInterruptsBlockedSend(t *testing.T) { | |
| 113 | 190 | writer := &blockedWriter{started: make(chan struct{}), released: make(chan struct{})} | |
| 114 | 191 | transport := NewTransport(nil, writer, nil) | |
M internal/app/launch_allowlist.go +12 -2
| @@ -40,9 +40,9 @@ type allowlistedRemoteHostStore struct { | |||
| 40 | 40 | allowed map[string]struct{} | |
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | - | func (s allowlistedRemoteHostStore) Hosts() (pinned, learned []string, err error) { | |
| 43 | + | func (s allowlistedRemoteHostStore) Hosts() (pinned, learned []domain.RemoteRegistration, err error) { | |
| 44 | 44 | 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 | |
| 46 | 46 | } | |
| 47 | 47 | ||
| 48 | 48 | func (s allowlistedRemoteHostStore) AddPinned(target string) error { | |
| @@ -143,6 +143,16 @@ func filterRemoteTargets(targets []string, allowed map[string]struct{}) []string | |||
| 143 | 143 | return filtered | |
| 144 | 144 | } | |
| 145 | 145 | ||
| 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 | + | ||
| 146 | 156 | func endpointNotConfiguredError() error { | |
| 147 | 157 | return &ports.UIError{Code: ports.UIErrEndpointNotConfigured} | |
| 148 | 158 | } | |
M internal/app/remote_hosts.go +17 -17
| @@ -232,29 +232,29 @@ func mergeKnownHosts(deps remoteHostDeps) ([]domain.RemoteHost, error) { | |||
| 232 | 232 | ||
| 233 | 233 | // mergeRemoteHosts returns pinned hosts in stored order, then learned-only | |
| 234 | 234 | // 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) | |
| 237 | 237 | 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{}{} | |
| 240 | 240 | } | |
| 241 | 241 | pinnedSet := make(map[string]struct{}, len(pinned)) | |
| 242 | 242 | 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) | |
| 252 | 252 | } | |
| 253 | 253 | } | |
| 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}) | |
| 258 | 258 | } | |
| 259 | 259 | return out | |
| 260 | 260 | } | |
M internal/app/remote_test.go +26 -7
| @@ -72,13 +72,32 @@ func TestDecodeSessionListErrorReply(t *testing.T) { | |||
| 72 | 72 | }) | |
| 73 | 73 | } | |
| 74 | 74 | ||
| 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 | + | ||
| 75 | 86 | 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 | + | ) | |
| 77 | 96 | 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}, | |
| 82 | 101 | }, got) | |
| 83 | 102 | } | |
| 84 | 103 | ||
| @@ -135,7 +154,7 @@ func TestRemoteHostCommands(t *testing.T) { | |||
| 135 | 154 | name: "list preserves source markers and order", | |
| 136 | 155 | run: func(t *testing.T) { | |
| 137 | 156 | 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() | |
| 139 | 158 | var out bytes.Buffer | |
| 140 | 159 | err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, &out)) | |
| 141 | 160 | require.NoError(t, err) | |
| @@ -173,7 +192,7 @@ func TestRemoteHostCommands(t *testing.T) { | |||
| 173 | 192 | ||
| 174 | 193 | func TestRemoteHostListingUsesUnifiedStore(t *testing.T) { | |
| 175 | 194 | 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() | |
| 177 | 196 | catalog := portsmocks.NewMockRemoteCatalogClient(t) | |
| 178 | 197 | catalog.EXPECT().List(mock.Anything, "arch").Return(catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "build", State: "up"}}}, nil).Once() | |
| 179 | 198 | var out bytes.Buffer | |
M internal/app/run.go +10 -6
| @@ -51,6 +51,7 @@ import ( | |||
| 51 | 51 | "github.com/bnema/vev/internal/usecase/client" | |
| 52 | 52 | "github.com/bnema/vev/internal/usecase/daemon" | |
| 53 | 53 | "github.com/bnema/vev/internal/usecase/recovery" | |
| 54 | + | "github.com/bnema/vev/internal/usecase/remotes" | |
| 54 | 55 | pdgram "github.com/bnema/vev/pkg/dgram" | |
| 55 | 56 | "github.com/bnema/vev/pkg/safedir" | |
| 56 | 57 | ) | |
| @@ -706,9 +707,9 @@ func runDaemonOwnedWithLogger(ctx context.Context, log *slog.Logger) (retErr err | |||
| 706 | 707 | } | |
| 707 | 708 | var remoteDiscoveryOpt daemon.Option | |
| 708 | 709 | if allowlistConfigured { | |
| 709 | - | remoteDiscoveryOpt, err = remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport), allowedRemoteEndpoints) | |
| 710 | + | remoteDiscoveryOpt, err = remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport), clk, log, allowedRemoteEndpoints) | |
| 710 | 711 | } else { | |
| 711 | - | remoteDiscoveryOpt, err = remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport)) | |
| 712 | + | remoteDiscoveryOpt, err = remoteDiscoveryDaemonOption(platform.StateDir(), os.Getenv(envRemoteTransport), clk, log) | |
| 712 | 713 | } | |
| 713 | 714 | if err != nil { | |
| 714 | 715 | return err | |
| @@ -925,8 +926,10 @@ func validateRemoteAttachHandoff(target protocol.AttachTarget) error { | |||
| 925 | 926 | } | |
| 926 | 927 | ||
| 927 | 928 | // 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) { | |
| 930 | 933 | _, err := remoteTransportModeFromEnv(transport) | |
| 931 | 934 | if err != nil { | |
| 932 | 935 | return nil, err | |
| @@ -942,11 +945,12 @@ func remoteDiscoveryDaemonOption(stateDir, transport string, allowlists ...map[s | |||
| 942 | 945 | cache = allowlistedRemoteCatalogCache{delegate: cache, allowed: allowed} | |
| 943 | 946 | previewClient = allowlistedRemotePreviewClient{delegate: previewClient, allowed: allowed} | |
| 944 | 947 | } | |
| 945 | - | discovery := daemon.WithRemoteDiscovery(store, catalog, cache) | |
| 946 | 948 | preview := daemon.WithRemotePreview(previewClient) | |
| 949 | + | monitor := remotes.NewMonitor(remoteadapter.NewRuntime(store, catalog, cache, log), clk, log) | |
| 950 | + | remoteMonitor := daemon.WithRemoteMonitor(monitor, monitor.Run) | |
| 947 | 951 | return func(d *daemon.Daemon) { | |
| 948 | - | discovery(d) | |
| 949 | 952 | preview(d) | |
| 953 | + | remoteMonitor(d) | |
| 950 | 954 | }, nil | |
| 951 | 955 | } | |
| 952 | 956 | ||
M internal/app/run_test.go +4 -3
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | "testing" | |
| 18 | 18 | "time" | |
| 19 | 19 | ||
| 20 | + | "github.com/bnema/vev/internal/adapters/clock" | |
| 20 | 21 | "github.com/bnema/vev/internal/adapters/lifecycle" | |
| 21 | 22 | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | |
| 22 | 23 | "github.com/bnema/vev/internal/adapters/sessionwire" | |
| @@ -1418,15 +1419,15 @@ func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) { | |||
| 1418 | 1419 | newRemoteCatalogClient = func() ports.RemoteCatalogClient { return nil } | |
| 1419 | 1420 | newRemoteDialerFactoryWithRuntimeObserver = func(ports.SerializedRuntimeObserver) remoteDialerForTarget { return nil } | |
| 1420 | 1421 | ||
| 1421 | - | option, err := remoteDiscoveryDaemonOption(stateDir, "stdio") | |
| 1422 | + | option, err := remoteDiscoveryDaemonOption(stateDir, "stdio", clock.New(), slog.New(slog.NewTextHandler(io.Discard, nil))) | |
| 1422 | 1423 | require.NoError(t, err) | |
| 1423 | 1424 | _ = daemon.New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), option) | |
| 1424 | 1425 | require.Equal(t, remoteadapter.HostStorePath(stateDir), hostPath) | |
| 1425 | 1426 | 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") | |
| 1427 | 1428 | } | |
| 1428 | 1429 | ||
| 1429 | 1430 | 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))) | |
| 1431 | 1432 | require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`) | |
| 1432 | 1433 | } | |
M internal/domain/remote_host.go +19 -3
| @@ -8,9 +8,10 @@ import ( | |||
| 8 | 8 | ||
| 9 | 9 | // RemoteHost is a merged remote host entry with its source markers. | |
| 10 | 10 | type RemoteHost struct { | |
| 11 | - | Target string | |
| 12 | - | Pinned bool | |
| 13 | - | Learned bool | |
| 11 | + | Target string | |
| 12 | + | Pinned bool | |
| 13 | + | Learned bool | |
| 14 | + | Registration RemoteRegistration | |
| 14 | 15 | } | |
| 15 | 16 | ||
| 16 | 17 | // ValidateRemoteHostTarget rejects empty targets, surrounding/internal whitespace, | |
| @@ -43,3 +44,18 @@ func UniqueRemoteHostTargets(values []string) []string { | |||
| 43 | 44 | } | |
| 44 | 45 | return out | |
| 45 | 46 | } | |
| 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) | |||
| 143 | 143 | } | |
| 144 | 144 | ||
| 145 | 145 | // 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) { | |
| 147 | 147 | ret := _mock.Called() | |
| 148 | 148 | ||
| 149 | 149 | if len(ret) == 0 { | |
| 150 | 150 | panic("no return value specified for Hosts") | |
| 151 | 151 | } | |
| 152 | 152 | ||
| 153 | - | var r0 []string | |
| 154 | - | var r1 []string | |
| 153 | + | var r0 []domain.RemoteRegistration | |
| 154 | + | var r1 []domain.RemoteRegistration | |
| 155 | 155 | 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 { | |
| 157 | 157 | return returnFunc() | |
| 158 | 158 | } | |
| 159 | - | if returnFunc, ok := ret.Get(0).(func() []string); ok { | |
| 159 | + | if returnFunc, ok := ret.Get(0).(func() []domain.RemoteRegistration); ok { | |
| 160 | 160 | r0 = returnFunc() | |
| 161 | 161 | } else { | |
| 162 | 162 | if ret.Get(0) != nil { | |
| 163 | - | r0 = ret.Get(0).([]string) | |
| 163 | + | r0 = ret.Get(0).([]domain.RemoteRegistration) | |
| 164 | 164 | } | |
| 165 | 165 | } | |
| 166 | - | if returnFunc, ok := ret.Get(1).(func() []string); ok { | |
| 166 | + | if returnFunc, ok := ret.Get(1).(func() []domain.RemoteRegistration); ok { | |
| 167 | 167 | r1 = returnFunc() | |
| 168 | 168 | } else { | |
| 169 | 169 | if ret.Get(1) != nil { | |
| 170 | - | r1 = ret.Get(1).([]string) | |
| 170 | + | r1 = ret.Get(1).([]domain.RemoteRegistration) | |
| 171 | 171 | } | |
| 172 | 172 | } | |
| 173 | 173 | if returnFunc, ok := ret.Get(2).(func() error); ok { | |
| @@ -195,12 +195,12 @@ func (_c *MockRemoteHostStore_Hosts_Call) Run(run func()) *MockRemoteHostStore_H | |||
| 195 | 195 | return _c | |
| 196 | 196 | } | |
| 197 | 197 | ||
| 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 { | |
| 199 | 199 | _c.Call.Return(pinned, learned, err) | |
| 200 | 200 | return _c | |
| 201 | 201 | } | |
| 202 | 202 | ||
| 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 { | |
| 204 | 204 | _c.Call.Return(run) | |
| 205 | 205 | return _c | |
| 206 | 206 | } | |
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 | |||
| 365 | 365 | } | |
| 366 | 366 | ||
| 367 | 367 | // 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. | |
| 368 | 373 | func (_mock *MockUIObservationSink) ObserveTerminalWrite(data []byte) { | |
| 369 | 374 | _mock.Called(append([]byte(nil), data...)) | |
| 370 | 375 | return | |
M internal/ports/remote.go +5 -2
| @@ -8,9 +8,12 @@ import ( | |||
| 8 | 8 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 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. | |
| 12 | 15 | type RemoteHostStore interface { | |
| 13 | - | Hosts() (pinned, learned []string, err error) | |
| 16 | + | Hosts() (pinned, learned []domain.RemoteRegistration, err error) | |
| 14 | 17 | AddPinned(target string) error | |
| 15 | 18 | RemovePinned(target string) error | |
| 16 | 19 | 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 ( | |||
| 29 | 29 | ) | |
| 30 | 30 | ||
| 31 | 31 | // 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. | |
| 32 | 34 | 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 | |
| 36 | 39 | } | |
| 37 | 40 | ||
| 38 | 41 | // RemoteCatalogTab is one bounded, untrusted tab presentation hint. | |
| @@ -264,7 +267,7 @@ func validText(value string) bool { | |||
| 264 | 267 | ||
| 265 | 268 | func validReason(reason string) bool { | |
| 266 | 269 | 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: | |
| 268 | 271 | return true | |
| 269 | 272 | default: | |
| 270 | 273 | return false | |
M internal/usecase/daemon/control.go +1 -1
| @@ -748,7 +748,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||
| 748 | 748 | reason := "" | |
| 749 | 749 | if entry.broken() { | |
| 750 | 750 | state = catalogue.RemoteCatalogSessionBroken | |
| 751 | - | reason = "session_broken" | |
| 751 | + | reason = domain.RemoteReasonSessionBroken | |
| 752 | 752 | } | |
| 753 | 753 | rows = append(rows, catalogue.RemoteCatalogSession{ | |
| 754 | 754 | LifecycleID: entry.incarnation, | |
M internal/usecase/daemon/control_test.go +24 -0
| @@ -737,6 +737,30 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) { | |||
| 737 | 737 | require.Equal(t, true, decoded[0]["focused"]) | |
| 738 | 738 | } | |
| 739 | 739 | ||
| 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 | + | ||
| 740 | 764 | func TestRemoteCatalogJSONOutput(t *testing.T) { | |
| 741 | 765 | d := newTestDaemon(t, nil, stubClock{}) | |
| 742 | 766 | work := addControlSession(d, "work", "t_work", "p_work") | |
M internal/usecase/daemon/daemon.go +75 -17
| @@ -298,14 +298,15 @@ type Daemon struct { | |||
| 298 | 298 | barScripts *barScriptState | |
| 299 | 299 | notices *noticeCenter | |
| 300 | 300 | 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 | |
| 309 | 310 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | |
| 310 | 311 | // (see clipboard.go); empty means use os.TempDir(). | |
| 311 | 312 | tempDir string | |
| @@ -415,12 +416,13 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option { | |||
| 415 | 416 | return func(d *Daemon) { d.runtimeObserver = observer } | |
| 416 | 417 | } | |
| 417 | 418 | ||
| 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 { | |
| 420 | 423 | return func(d *Daemon) { | |
| 421 | - | d.remoteHostStore = store | |
| 422 | - | d.remoteCatalogClient = catalog | |
| 423 | - | d.remoteCatalogCache = cache | |
| 424 | + | d.remoteDirectory = directory | |
| 425 | + | d.remoteMonitorRun = run | |
| 424 | 426 | } | |
| 425 | 427 | } | |
| 426 | 428 | ||
| @@ -606,7 +608,18 @@ func (d *Daemon) allocateLifecycleCreatedAtLocked() (int64, error) { | |||
| 606 | 608 | } | |
| 607 | 609 | ||
| 608 | 610 | 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() | |
| 610 | 623 | } | |
| 611 | 624 | ||
| 612 | 625 | type systemClock struct{} | |
| @@ -670,7 +683,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 670 | 683 | snapshotAdmitted: make(map[*snapshotCapture]struct{}), | |
| 671 | 684 | snapshotWake: make(chan struct{}, 1), | |
| 672 | 685 | notices: newNoticeCenter(), | |
| 673 | - | remoteCatalog: newRemoteCatalogState(), | |
| 674 | 686 | resumeParkGrace: defaultResumeParkGrace, | |
| 675 | 687 | barScripts: &barScriptState{ | |
| 676 | 688 | cfg: barConfigFromDomain(domain.Defaults().Bar), | |
| @@ -730,10 +742,14 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||
| 730 | 742 | d.lastAllocatedCreatedAt = maxCreatedAt | |
| 731 | 743 | } | |
| 732 | 744 | d.mruSeq.Store(maxSeq) | |
| 733 | - | d.loadRemoteCatalogCache() | |
| 734 | 745 | return d | |
| 735 | 746 | } | |
| 736 | 747 | ||
| 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 | + | ||
| 737 | 753 | // Serve runs the accept loop over l, owning it for the loop's lifetime. It | |
| 738 | 754 | // returns when the last session is removed or ctx is cancelled; on the latter | |
| 739 | 755 | // path attached clients are detached with ReasonServerShutdown. | |
| @@ -743,6 +759,49 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error { | |||
| 743 | 759 | d.hardCtx, d.hardCancel = context.WithCancel(context.Background()) | |
| 744 | 760 | defer d.hardCancel() | |
| 745 | 761 | ||
| 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 | + | ||
| 746 | 805 | d.sessWg.Go(func() { | |
| 747 | 806 | d.attentionAnimator(d.serveCtx) | |
| 748 | 807 | }) | |
| @@ -855,7 +914,6 @@ func (d *Daemon) purgeAll(reason uint8) (checkpointIncomplete bool) { | |||
| 855 | 914 | } | |
| 856 | 915 | ||
| 857 | 916 | func (d *Daemon) terminateAllWithSnapshotDeadline(reason uint8, purge bool, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) { | |
| 858 | - | d.cancelRemoteDiscoveryRefresh() | |
| 859 | 917 | d.closeMoveLifecycles() | |
| 860 | 918 | d.mu.Lock() | |
| 861 | 919 | d.closing = true | |
M internal/usecase/daemon/move_picker.go +14 -2
| @@ -195,15 +195,27 @@ func (d *Daemon) refreshPickerOpts(ac *attachedClient, opts pickerRefreshOptions | |||
| 195 | 195 | } | |
| 196 | 196 | rt.pickerMu.Lock() | |
| 197 | 197 | 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 | |
| 198 | 200 | if updated { | |
| 201 | + | before, haveBefore = rt.picker.Selected() | |
| 199 | 202 | rt.picker.ReplaceFrom(model) | |
| 200 | 203 | if intent == pickerNavigate && opts.nearestRow >= 0 { | |
| 201 | 204 | rt.picker.SelectNearestRow(opts.nearestRow) | |
| 202 | 205 | } | |
| 203 | 206 | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | |
| 207 | + | after, haveAfter = rt.picker.Selected() | |
| 204 | 208 | } | |
| 205 | 209 | 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 | |
| 208 | 219 | } | |
| 220 | + | d.registerPreviewForSelection(ac) | |
| 209 | 221 | } | |
M internal/usecase/daemon/overlay_runtime.go +2 -4
| @@ -39,10 +39,8 @@ type overlayRuntime struct { | |||
| 39 | 39 | pickerESC pendingByteTimer | |
| 40 | 40 | ||
| 41 | 41 | // 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) | |
| 46 | 44 | ||
| 47 | 45 | paletteMu sync.Mutex | |
| 48 | 46 | palette *palette.Model | |
M internal/usecase/daemon/palette.go +52 -46
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | renderer "github.com/bnema/vev-vt" | |
| 9 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | 11 | "github.com/bnema/vev/internal/protocol" | |
| 11 | 12 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 12 | 13 | "github.com/bnema/vev/internal/usecase/command" | |
| @@ -56,12 +57,8 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||
| 56 | 57 | ac.overlays.palettePreview = "" | |
| 57 | 58 | ac.overlays.paletteFeedback = "" | |
| 58 | 59 | ac.overlays.palettePending = nil | |
| 59 | - | generation := ac.overlays.paletteGeneration | |
| 60 | 60 | ac.overlays.paletteMu.Unlock() | |
| 61 | 61 | d.invalidateRender(sess, ac, true, "palette.go") | |
| 62 | - | d.remoteDiscoveryOpened(remoteDiscoveryInstance{ | |
| 63 | - | ac: ac, kind: remoteDiscoveryPalette, generation: generation, palette: model, | |
| 64 | - | }) | |
| 65 | 62 | } | |
| 66 | 63 | ||
| 67 | 64 | type paletteSessionIdentity struct { | |
| @@ -118,9 +115,10 @@ func routeEntryForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteR | |||
| 118 | 115 | return protocol.RecentRouteEntry{}, false | |
| 119 | 116 | } | |
| 120 | 117 | ||
| 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) | |
| 123 | 120 | servingOrigin := "" | |
| 121 | + | servingEndpoint := "" | |
| 124 | 122 | if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal { | |
| 125 | 123 | results = append(results, palette.NewCreateSessionDestination( | |
| 126 | 124 | palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation, | |
| @@ -128,28 +126,42 @@ func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, curr | |||
| 128 | 126 | } | |
| 129 | 127 | if active, ok := activeRouteEntryForLifecycle(snapshot, currentLifecycle); ok && active.Kind == protocol.RouteKindRemote { | |
| 130 | 128 | servingOrigin = paletteRemoteDisplayOrigin(active.HostLabel) | |
| 129 | + | servingEndpoint = servingRouteEndpoint(hosts, active.Target.LifecycleID) | |
| 131 | 130 | results = append(results, palette.NewCreateSessionDestination( | |
| 132 | 131 | palette.CreateSessionOnServingDaemon, servingOrigin, "", protocol.RouteRef{}, 0, | |
| 133 | 132 | )) | |
| 134 | 133 | } | |
| 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 == "" { | |
| 140 | 136 | continue | |
| 141 | 137 | } | |
| 142 | - | displayOrigin := paletteRemoteDisplayOrigin(host.entry.Host) | |
| 143 | - | if servingOrigin != "" && displayOrigin == servingOrigin { | |
| 138 | + | displayOrigin := paletteRemoteDisplayOrigin(host.Endpoint) | |
| 139 | + | if servingEndpoint != "" && host.Endpoint == servingEndpoint { | |
| 144 | 140 | continue | |
| 145 | 141 | } | |
| 146 | 142 | results = append(results, palette.NewCreateSessionDestination( | |
| 147 | - | palette.CreateSessionOnRemoteHost, displayOrigin, host.entry.Host, protocol.RouteRef{}, 0, | |
| 143 | + | palette.CreateSessionOnRemoteHost, displayOrigin, host.Endpoint, protocol.RouteRef{}, 0, | |
| 148 | 144 | )) | |
| 149 | 145 | } | |
| 150 | 146 | return results | |
| 151 | 147 | } | |
| 152 | 148 | ||
| 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 | + | ||
| 153 | 165 | func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool { | |
| 154 | 166 | if entry.Kind == protocol.RouteKindLocal { | |
| 155 | 167 | return true | |
| @@ -158,11 +170,16 @@ func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemon | |||
| 158 | 170 | paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin | |
| 159 | 171 | } | |
| 160 | 172 | ||
| 161 | - | func remotePaletteUnavailableReason(status remoteHostStatus, session catalogue.RemoteCatalogSession) string { | |
| 173 | + | func remotePaletteUnavailableReason(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession) string { | |
| 162 | 174 | if session.State == catalogue.RemoteCatalogSessionBroken { | |
| 163 | - | return "session_broken" | |
| 175 | + | return domain.RemoteReasonSessionBroken | |
| 164 | 176 | } | |
| 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 | |
| 166 | 183 | } | |
| 167 | 184 | ||
| 168 | 185 | // paletteResults captures eligible named sessions before paletteMu so the | |
| @@ -187,15 +204,15 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 187 | 204 | } | |
| 188 | 205 | daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle) | |
| 189 | 206 | ||
| 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) | |
| 193 | 210 | remoteSessionCount := 0 | |
| 194 | - | for _, host := range remoteCatalog { | |
| 195 | - | remoteSessionCount += len(host.entry.Sessions) | |
| 211 | + | for _, host := range hosts { | |
| 212 | + | remoteSessionCount += len(host.Sessions) | |
| 196 | 213 | } | |
| 197 | 214 | ||
| 198 | - | destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle, remoteCatalog, hostRanks) | |
| 215 | + | destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle, hosts) | |
| 199 | 216 | results := make([]palette.Result, 0, len(commands)+len(destinations)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries)) | |
| 200 | 217 | for _, cmd := range commands { | |
| 201 | 218 | results = append(results, palette.NewCommandResult(cmd)) | |
| @@ -232,13 +249,13 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 232 | 249 | } | |
| 233 | 250 | discovered := make([]discoveredRemote, 0, remoteSessionCount) | |
| 234 | 251 | 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 { | |
| 237 | 254 | if session.Ephemeral { | |
| 238 | 255 | continue | |
| 239 | 256 | } | |
| 240 | 257 | key, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{ | |
| 241 | - | Host: host.entry.Host, Name: session.Name, | |
| 258 | + | Host: host.Endpoint, Name: session.Name, | |
| 242 | 259 | }, session) | |
| 243 | 260 | if key.Validate() != nil || target.Validate() != nil { | |
| 244 | 261 | continue | |
| @@ -249,7 +266,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||
| 249 | 266 | } | |
| 250 | 267 | discovered = append(discovered, discoveredRemote{ | |
| 251 | 268 | identity: identity, | |
| 252 | - | result: palette.NewRemoteSessionResult(key, target, remotePaletteUnavailableReason(host.status, session)), | |
| 269 | + | result: palette.NewRemoteSessionResult(key, target, remotePaletteUnavailableReason(host, session)), | |
| 253 | 270 | }) | |
| 254 | 271 | discoveredByPresentation[presentation] = append(discoveredByPresentation[presentation], identity) | |
| 255 | 272 | } | |
| @@ -408,7 +425,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 408 | 425 | var generation uint64 | |
| 409 | 426 | var rawQuery string | |
| 410 | 427 | changed, cancel, execute, chooseDestination := false, false, false, false | |
| 411 | - | var closedDiscovery remoteDiscoveryInstance | |
| 412 | 428 | var effect *attachmentEffect | |
| 413 | 429 | if len(effects) != 0 { | |
| 414 | 430 | effect = effects[0] | |
| @@ -530,11 +546,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||
| 530 | 546 | } | |
| 531 | 547 | } | |
| 532 | 548 | if cancel { | |
| 533 | - | closedDiscovery = ac.clearPaletteLocked() | |
| 549 | + | ac.clearPaletteLocked() | |
| 534 | 550 | } | |
| 535 | 551 | ac.overlays.paletteMu.Unlock() | |
| 536 | 552 | if cancel { | |
| 537 | - | d.remoteDiscoveryClosed(closedDiscovery) | |
| 538 | 553 | d.invalidateRender(entry, ac, true, "palette.go") | |
| 539 | 554 | return | |
| 540 | 555 | } | |
| @@ -715,8 +730,7 @@ func paletteArgs(query string, cmd command.Command) []string { | |||
| 715 | 730 | return action.Args | |
| 716 | 731 | } | |
| 717 | 732 | ||
| 718 | - | func (ac *attachedClient) clearPaletteLocked() remoteDiscoveryInstance { | |
| 719 | - | model := ac.overlays.palette | |
| 733 | + | func (ac *attachedClient) clearPaletteLocked() { | |
| 720 | 734 | ac.overlays.paletteGeneration++ | |
| 721 | 735 | ac.overlays.palette = nil | |
| 722 | 736 | ac.overlays.paletteRouteSnapshot = protocol.RecentRouteSnapshot{} | |
| @@ -724,9 +738,6 @@ func (ac *attachedClient) clearPaletteLocked() remoteDiscoveryInstance { | |||
| 724 | 738 | ac.overlays.palettePreview = "" | |
| 725 | 739 | ac.overlays.paletteFeedback = "" | |
| 726 | 740 | ac.overlays.palettePending = nil | |
| 727 | - | return remoteDiscoveryInstance{ | |
| 728 | - | ac: ac, kind: remoteDiscoveryPalette, generation: ac.overlays.paletteGeneration, palette: model, | |
| 729 | - | } | |
| 730 | 741 | } | |
| 731 | 742 | ||
| 732 | 743 | func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) { | |
| @@ -744,9 +755,8 @@ func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, raw | |||
| 744 | 755 | ac.overlays.paletteMu.Unlock() | |
| 745 | 756 | return false | |
| 746 | 757 | } | |
| 747 | - | instance := ac.clearPaletteLocked() | |
| 758 | + | ac.clearPaletteLocked() | |
| 748 | 759 | ac.overlays.paletteMu.Unlock() | |
| 749 | - | d.remoteDiscoveryClosed(instance) | |
| 750 | 760 | return true | |
| 751 | 761 | } | |
| 752 | 762 | ||
| @@ -763,9 +773,8 @@ func (d *Daemon) closePaletteIfCurrent(ac *attachedClient, generation uint64) bo | |||
| 763 | 773 | ac.overlays.paletteMu.Unlock() | |
| 764 | 774 | return false | |
| 765 | 775 | } | |
| 766 | - | instance := ac.clearPaletteLocked() | |
| 776 | + | ac.clearPaletteLocked() | |
| 767 | 777 | ac.overlays.paletteMu.Unlock() | |
| 768 | - | d.remoteDiscoveryClosed(instance) | |
| 769 | 778 | return true | |
| 770 | 779 | } | |
| 771 | 780 | ||
| @@ -927,13 +936,10 @@ func (d *Daemon) remoteCreateHostReady(endpoint string) bool { | |||
| 927 | 936 | if d == nil || domain.ValidateRemoteHostTarget(endpoint) != nil { | |
| 928 | 937 | return false | |
| 929 | 938 | } | |
| 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) | |
| 937 | 943 | return ok | |
| 938 | 944 | } | |
| 939 | 945 | ||
M internal/usecase/daemon/palette_test.go +90 -87
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | renderer "github.com/bnema/vev-vt" | |
| 13 | 13 | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | 15 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 15 | 16 | "github.com/bnema/vev/internal/protocol" | |
| 16 | 17 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| @@ -62,15 +63,15 @@ func TestCreateSessionDestinationResultsUseStructuredRouteAuthority(t *testing.T | |||
| 62 | 63 | Target: testRouteTarget("home", 2), Name: "home", | |
| 63 | 64 | }}, | |
| 64 | 65 | } | |
| 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"}, | |
| 70 | 71 | } | |
| 71 | 72 | ||
| 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) | |
| 74 | 75 | _, kind, _, endpoint, route, ok := results[0].CreateSessionDestination() | |
| 75 | 76 | require.True(t, ok) | |
| 76 | 77 | require.Equal(t, palette.CreateSessionOnLocalRoute, kind) | |
| @@ -89,6 +90,13 @@ func TestCreateSessionDestinationResultsUseStructuredRouteAuthority(t *testing.T | |||
| 89 | 90 | require.Equal(t, palette.CreateSessionOnRemoteHost, kind) | |
| 90 | 91 | require.Equal(t, "host-b", origin) | |
| 91 | 92 | 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) | |
| 92 | 100 | } | |
| 93 | 101 | ||
| 94 | 102 | func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) { | |
| @@ -99,6 +107,38 @@ func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) { | |||
| 99 | 107 | require.ErrorIs(t, err, errCreateDestinationUnavailable) | |
| 100 | 108 | } | |
| 101 | 109 | ||
| 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 | + | ||
| 102 | 142 | func TestCreateSessionDestinationResultsDoNotInferLocalFromRemoteHome(t *testing.T) { | |
| 103 | 143 | lifecycle := domain.SessionLifecycleID{1} | |
| 104 | 144 | snapshot := protocol.RecentRouteSnapshot{ | |
| @@ -110,7 +150,7 @@ func TestCreateSessionDestinationResultsDoNotInferLocalFromRemoteHome(t *testing | |||
| 110 | 150 | Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work", | |
| 111 | 151 | }, | |
| 112 | 152 | } | |
| 113 | - | results := createSessionDestinationResults(snapshot, lifecycle, nil, nil) | |
| 153 | + | results := createSessionDestinationResults(snapshot, lifecycle, nil) | |
| 114 | 154 | require.Len(t, results, 1) | |
| 115 | 155 | _, kind, _, _, _, ok := results[0].CreateSessionDestination() | |
| 116 | 156 | require.True(t, ok) | |
| @@ -508,16 +548,10 @@ func TestPaletteIncludesExactRemoteCatalogTargetBesideSameNameLocalSession(t *te | |||
| 508 | 548 | local := addControlSession(d, "vev", "tab-2", "pane-2") | |
| 509 | 549 | local.ephemeral = false | |
| 510 | 550 | 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 | + | })) | |
| 521 | 555 | ||
| 522 | 556 | results := d.paletteResults(current, nil, protocol.RecentRouteSnapshot{}) | |
| 523 | 557 | var matching []palette.Result | |
| @@ -595,18 +629,14 @@ func TestPaletteMatchesRecentRemoteRouteToCatalog(t *testing.T) { | |||
| 595 | 629 | current := addControlSession(d, "current", "tab-1", "pane-1") | |
| 596 | 630 | current.ephemeral = false | |
| 597 | 631 | lifecycle := domain.SessionLifecycleID{22} | |
| 598 | - | d.remoteCatalog.mu.Lock() | |
| 632 | + | hosts := make([]ports.RemoteHostSnapshot, 0, len(test.catalogEndpoints)) | |
| 599 | 633 | 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 | + | })) | |
| 608 | 638 | } | |
| 609 | - | d.remoteCatalog.mu.Unlock() | |
| 639 | + | seedRemoteDirectory(t, d, hosts...) | |
| 610 | 640 | ||
| 611 | 641 | results := d.paletteResults(current, nil, protocol.RecentRouteSnapshot{ | |
| 612 | 642 | Generation: 2, | |
| @@ -646,16 +676,10 @@ func TestPaletteRemoteCatalogSelectionSendsExactAttachTarget(t *testing.T) { | |||
| 646 | 676 | now := time.Unix(1_000, 0) | |
| 647 | 677 | d.clock = fixedRemoteRefreshClock{now: now} | |
| 648 | 678 | 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 | + | })) | |
| 659 | 683 | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 660 | 684 | ||
| 661 | 685 | d.handleInputForAttachment(token, []byte("\x1b ")) | |
| @@ -674,27 +698,20 @@ func TestPaletteRemoteCatalogSelectionSendsExactAttachTarget(t *testing.T) { | |||
| 674 | 698 | RemoteTarget: &remoteTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 675 | 699 | }, target) | |
| 676 | 700 | 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() | |
| 680 | 701 | } | |
| 681 | 702 | ||
| 682 | - | func TestPaletteCachedRemoteSelectionFailsClosed(t *testing.T) { | |
| 703 | + | func TestPaletteUnknownRemoteSelectionFailsClosed(t *testing.T) { | |
| 683 | 704 | p, release := newBlockingPTY(t) | |
| 684 | 705 | defer release() | |
| 685 | 706 | d, current, ac, sends := newManualSessionWithPTYs(t, p) | |
| 686 | 707 | now := time.Unix(1_000, 0) | |
| 687 | 708 | 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 | + | }) | |
| 698 | 715 | token := beginRecentRoutePaletteEffect(t, d, current, ac) | |
| 699 | 716 | ||
| 700 | 717 | d.handleInputForAttachment(token, []byte("\x1b ")) | |
| @@ -703,10 +720,6 @@ func TestPaletteCachedRemoteSelectionFailsClosed(t *testing.T) { | |||
| 703 | 720 | ||
| 704 | 721 | require.Same(t, current, ac.currentAttachmentSession()) | |
| 705 | 722 | 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") | |
| 710 | 723 | for { | |
| 711 | 724 | select { | |
| 712 | 725 | case frame := <-sends: | |
| @@ -717,25 +730,27 @@ func TestPaletteCachedRemoteSelectionFailsClosed(t *testing.T) { | |||
| 717 | 730 | } | |
| 718 | 731 | } | |
| 719 | 732 | ||
| 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) | |
| 723 | 736 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "current") | |
| 724 | 737 | ||
| 725 | 738 | d.enterPalette(sess, ac) | |
| 726 | - | request := receiveRemotePicker(t, catalog.requests, "palette catalog request") | |
| 727 | 739 | ac.overlays.paletteMu.Lock() | |
| 728 | 740 | model := ac.overlays.palette | |
| 741 | + | require.NotNil(t, model) | |
| 729 | 742 | for _, r := range "vev" { | |
| 730 | 743 | model.Insert(r) | |
| 731 | 744 | } | |
| 732 | 745 | 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{ | |
| 734 | 750 | LifecycleID: domain.SessionLifecycleID{41}, Name: "vev", State: catalogue.RemoteCatalogSessionUp, | |
| 735 | 751 | 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) | |
| 739 | 754 | ||
| 740 | 755 | ac.overlays.paletteMu.Lock() | |
| 741 | 756 | require.Same(t, model, ac.overlays.palette) | |
| @@ -749,39 +764,27 @@ func TestRemoteRefreshUpdatesOpenPaletteAndPreservesQuery(t *testing.T) { | |||
| 749 | 764 | require.Contains(t, displayTexts, "Switch to session vev@arch") | |
| 750 | 765 | ||
| 751 | 766 | 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") | |
| 756 | 768 | } | |
| 757 | 769 | ||
| 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"))) | |
| 761 | 773 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 762 | 774 | ||
| 775 | + | // Opening overlays builds from the latest snapshot without requesting | |
| 776 | + | // reconciliation; closing one overlay leaves the other intact. | |
| 763 | 777 | d.publishPicker(sess, ac, d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| 764 | - | request := receiveRemotePicker(t, catalog.requests, "picker catalog request") | |
| 765 | 778 | 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()) | |
| 768 | 781 | ||
| 769 | 782 | 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") | |
| 778 | 785 | ||
| 779 | 786 | 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()) | |
| 785 | 788 | } | |
| 786 | 789 | ||
| 787 | 790 | func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { | |
M internal/usecase/daemon/picker.go +53 -56
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | renderer "github.com/bnema/vev-vt" | |
| 11 | 11 | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 12 | 13 | "github.com/bnema/vev/internal/protocol" | |
| 13 | 14 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 14 | 15 | "github.com/bnema/vev/internal/usecase/keys" | |
| @@ -75,7 +76,6 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker. | |||
| 75 | 76 | rt.pickerPreview = nil | |
| 76 | 77 | rt.picker = model | |
| 77 | 78 | rt.pickerGeneration++ | |
| 78 | - | instance := remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: model} | |
| 79 | 79 | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | |
| 80 | 80 | rt.pickerIntent = intent | |
| 81 | 81 | rt.pickerSource = source | |
| @@ -85,10 +85,6 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker. | |||
| 85 | 85 | d.teardownPreviewSubscription(ac, previous, previousGeneration) | |
| 86 | 86 | d.registerPreviewForSelection(ac) | |
| 87 | 87 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 88 | - | if rt.beforeRemotePickerRegistration != nil { | |
| 89 | - | rt.beforeRemotePickerRegistration() | |
| 90 | - | } | |
| 91 | - | d.remoteDiscoveryOpened(instance.discoveryInstance()) | |
| 92 | 88 | } | |
| 93 | 89 | ||
| 94 | 90 | // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally | |
| @@ -167,13 +163,16 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 167 | 163 | }) | |
| 168 | 164 | } | |
| 169 | 165 | ||
| 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() | |
| 172 | 171 | ||
| 173 | 172 | 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 { | |
| 177 | 176 | catalogRows++ | |
| 178 | 177 | } | |
| 179 | 178 | } | |
| @@ -187,33 +186,43 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||
| 187 | 186 | views = append(views, view) | |
| 188 | 187 | } | |
| 189 | 188 | publishedRemote := false | |
| 190 | - | for _, host := range catalog { | |
| 189 | + | for _, host := range hosts { | |
| 191 | 190 | 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} | |
| 194 | 193 | if key.Validate() != nil { | |
| 195 | 194 | continue | |
| 196 | 195 | } | |
| 197 | - | view := remotePickerView(key, session, host.status, host.entry.FetchedAt) | |
| 196 | + | view := remotePickerView(key, session, host, now) | |
| 198 | 197 | if grouped { | |
| 199 | 198 | view.HideRemoteOrigin = true | |
| 200 | 199 | if publishedForHost == 0 { | |
| 201 | - | view.Section = "REMOTE " + host.entry.Host | |
| 200 | + | view.Section = "REMOTE " + host.Endpoint | |
| 202 | 201 | } | |
| 203 | 202 | } | |
| 204 | 203 | views = append(views, view) | |
| 205 | 204 | publishedForHost++ | |
| 206 | 205 | publishedRemote = true | |
| 207 | 206 | } | |
| 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) | |
| 210 | 209 | if grouped { | |
| 211 | - | view.Section = "REMOTE " + host.entry.Host | |
| 210 | + | view.Section = "REMOTE " + host.Endpoint | |
| 212 | 211 | } | |
| 213 | 212 | views = append(views, view) | |
| 214 | 213 | publishedRemote = true | |
| 215 | 214 | } | |
| 216 | 215 | } | |
| 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 | + | } | |
| 217 | 226 | for i, s := range stopped { | |
| 218 | 227 | createdAt := s.createdAt | |
| 219 | 228 | view := picker.SessionView{ | |
| @@ -253,7 +262,7 @@ func attentionSuffix(label string) string { | |||
| 253 | 262 | func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Target) { | |
| 254 | 263 | reason := target.UnavailableReason | |
| 255 | 264 | if reason == "" { | |
| 256 | - | reason = "identity_changed" | |
| 265 | + | reason = domain.RemoteReasonIdentityChanged | |
| 257 | 266 | } | |
| 258 | 267 | message := "Remote session unavailable" | |
| 259 | 268 | if target.RemoteTarget != nil { | |
| @@ -267,21 +276,23 @@ func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Targ | |||
| 267 | 276 | ||
| 268 | 277 | func remotePickerReasonText(reason string) string { | |
| 269 | 278 | switch reason { | |
| 270 | - | case "catalog_stale": | |
| 279 | + | case domain.RemoteReasonCatalogStale: | |
| 271 | 280 | return "catalog stale" | |
| 272 | - | case "host_unreachable": | |
| 281 | + | case domain.RemoteReasonHostUnreachable: | |
| 273 | 282 | return "host unreachable" | |
| 274 | - | case "version_mismatch": | |
| 283 | + | case domain.RemoteReasonVersionMismatch: | |
| 275 | 284 | return "version mismatch" | |
| 276 | - | case "session_stopped": | |
| 285 | + | case domain.RemoteReasonSessionStopped: | |
| 277 | 286 | return "session stopped" | |
| 278 | - | case "session_broken": | |
| 287 | + | case domain.RemoteReasonSessionBroken: | |
| 279 | 288 | return "session broken" | |
| 280 | - | case "malformed": | |
| 289 | + | case domain.RemoteReasonMalformed: | |
| 281 | 290 | return "catalog malformed" | |
| 282 | - | case "refreshing": | |
| 291 | + | case domain.RemoteReasonAuthFailure: | |
| 292 | + | return "authentication failed" | |
| 293 | + | case domain.RemoteReasonRefreshing: | |
| 283 | 294 | return "refreshing" | |
| 284 | - | case "identity_changed": | |
| 295 | + | case domain.RemoteReasonIdentityChanged: | |
| 285 | 296 | return "session identity changed" | |
| 286 | 297 | default: | |
| 287 | 298 | return "unavailable" | |
| @@ -314,7 +325,6 @@ func pickerSearchSlashIndex(data, pending []byte) int { | |||
| 314 | 325 | func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |
| 315 | 326 | rt := ac.overlays | |
| 316 | 327 | var previewGeneration uint64 | |
| 317 | - | var instance remotePickerInstance | |
| 318 | 328 | return listInputState{ | |
| 319 | 329 | pending: &rt.pickerPending, | |
| 320 | 330 | esc: &rt.pickerESC, | |
| @@ -324,7 +334,6 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||
| 324 | 334 | unlock: rt.pickerMu.Unlock, | |
| 325 | 335 | active: func() bool { return rt.picker != nil }, | |
| 326 | 336 | closeLocked: func() { | |
| 327 | - | instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker} | |
| 328 | 337 | rt.picker = nil | |
| 329 | 338 | rt.pickerIntent = pickerNavigate | |
| 330 | 339 | rt.pickerSource = moveSourceLocator{} | |
| @@ -332,7 +341,6 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||
| 332 | 341 | }, | |
| 333 | 342 | afterClose: func() { | |
| 334 | 343 | d.clearPreviewGeneration(ac, previewGeneration) | |
| 335 | - | d.remoteDiscoveryClosed(instance.discoveryInstance()) | |
| 336 | 344 | if sess := ac.currentAttachmentSession(); sess != nil { | |
| 337 | 345 | d.invalidateRender(sess, ac, true, "picker.go") | |
| 338 | 346 | } | |
| @@ -550,7 +558,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||
| 550 | 558 | ac.overlays.pickerMu.Lock() | |
| 551 | 559 | // Selection may have changed while the target was resolved. | |
| 552 | 560 | 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) | |
| 554 | 562 | if valid { | |
| 555 | 563 | ac.overlays.pickerPreview = next | |
| 556 | 564 | ac.overlays.pickerPreviewSession = targetSess | |
| @@ -633,7 +641,7 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ | |||
| 633 | 641 | matching := false | |
| 634 | 642 | if ac.overlays.pickerPreviewGeneration == generation && ac.overlays.picker != nil { | |
| 635 | 643 | selected, stillSelected := ac.overlays.picker.Selected() | |
| 636 | - | matching = stillSelected && pickerTargetsEqual(selected, target) | |
| 644 | + | matching = stillSelected && pickerRouteTargetsEqual(selected, target) | |
| 637 | 645 | if matching { | |
| 638 | 646 | ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, "remote preview unavailable") | |
| 639 | 647 | } | |
| @@ -659,7 +667,7 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ | |||
| 659 | 667 | return | |
| 660 | 668 | } | |
| 661 | 669 | selected, stillSelected := ac.overlays.picker.Selected() | |
| 662 | - | if !stillSelected || !pickerTargetsEqual(selected, target) { | |
| 670 | + | if !stillSelected || !pickerRouteTargetsEqual(selected, target) { | |
| 663 | 671 | ac.overlays.pickerMu.Unlock() | |
| 664 | 672 | return | |
| 665 | 673 | } | |
| @@ -819,7 +827,6 @@ func (d *Daemon) closePickerIfCurrentRefresh(ac *attachedClient, model *picker.M | |||
| 819 | 827 | ac.overlays.pickerMu.Unlock() | |
| 820 | 828 | return false | |
| 821 | 829 | } | |
| 822 | - | instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker} | |
| 823 | 830 | ac.overlays.picker = nil | |
| 824 | 831 | ac.overlays.pickerTitle = "" | |
| 825 | 832 | ac.overlays.pickerIntent = pickerNavigate | |
| @@ -829,16 +836,16 @@ func (d *Daemon) closePickerIfCurrentRefresh(ac *attachedClient, model *picker.M | |||
| 829 | 836 | previewGeneration := ac.overlays.pickerPreviewGeneration | |
| 830 | 837 | ac.overlays.pickerMu.Unlock() | |
| 831 | 838 | d.clearPreviewGeneration(ac, previewGeneration) | |
| 832 | - | if instance.model != nil { | |
| 833 | - | d.remoteDiscoveryClosed(instance.discoveryInstance()) | |
| 834 | - | } | |
| 835 | 839 | return true | |
| 836 | 840 | } | |
| 837 | 841 | ||
| 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 { | |
| 839 | 846 | if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name || | |
| 840 | 847 | 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) || | |
| 842 | 849 | !remoteTargetsEqual(left.RemoteTarget, right.RemoteTarget) { | |
| 843 | 850 | return false | |
| 844 | 851 | } | |
| @@ -1107,29 +1114,19 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(effect *attachmentEffect, t | |||
| 1107 | 1114 | return nil | |
| 1108 | 1115 | } | |
| 1109 | 1116 | ||
| 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. | |
| 1122 | 1121 | func (d *Daemon) remoteCatalogTargetReady(target domain.RemoteSessionTarget) bool { | |
| 1123 | 1122 | if d == nil || target.Validate() != nil { | |
| 1124 | 1123 | return false | |
| 1125 | 1124 | } | |
| 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 { | |
| 1130 | 1127 | return false | |
| 1131 | 1128 | } | |
| 1132 | - | for _, session := range entry.Sessions { | |
| 1129 | + | for _, session := range host.Sessions { | |
| 1133 | 1130 | if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID { | |
| 1134 | 1131 | continue | |
| 1135 | 1132 | } | |
M internal/usecase/daemon/remote_attach_test.go +15 -30
| @@ -20,7 +20,7 @@ func remoteLifecycleForTest() domain.SessionLifecycleID { | |||
| 20 | 20 | } | |
| 21 | 21 | ||
| 22 | 22 | func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) { | |
| 23 | - | d := newRemotePickerDaemon(nil) | |
| 23 | + | d := newRemotePickerDaemon() | |
| 24 | 24 | source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source") | |
| 25 | 25 | target, _, _ := addRemoteRefreshPickerOwner(t, d, "target") | |
| 26 | 26 | target.incarnation = remoteLifecycleForTest() | |
| @@ -41,7 +41,7 @@ func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) { | |||
| 41 | 41 | } | |
| 42 | 42 | ||
| 43 | 43 | func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) { | |
| 44 | - | d := newRemotePickerDaemon(nil) | |
| 44 | + | d := newRemotePickerDaemon() | |
| 45 | 45 | source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source") | |
| 46 | 46 | lifecycle := remoteLifecycleForTest() | |
| 47 | 47 | d.inactive["stopped"] = inactiveSession{ | |
| @@ -74,16 +74,11 @@ func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) { | |||
| 74 | 74 | } | |
| 75 | 75 | ||
| 76 | 76 | func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) { | |
| 77 | - | d := newRemotePickerDaemon(nil) | |
| 77 | + | d := newRemotePickerDaemon() | |
| 78 | 78 | 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 | + | })) | |
| 87 | 82 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 88 | 83 | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 89 | 84 | effect, admitted := ac.beginAttachmentEffect(token) | |
| @@ -115,17 +110,12 @@ func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) { | |||
| 115 | 110 | }}, | |
| 116 | 111 | } { | |
| 117 | 112 | t.Run(test.name, func(t *testing.T) { | |
| 118 | - | d := newRemotePickerDaemon(nil) | |
| 113 | + | d := newRemotePickerDaemon() | |
| 119 | 114 | 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 | + | })) | |
| 129 | 119 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 130 | 120 | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 131 | 121 | effect, admitted := ac.beginAttachmentEffect(token) | |
| @@ -151,18 +141,13 @@ func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) { | |||
| 151 | 141 | } | |
| 152 | 142 | ||
| 153 | 143 | func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) { | |
| 154 | - | d := newRemotePickerDaemon(nil) | |
| 144 | + | d := newRemotePickerDaemon() | |
| 155 | 145 | lifecycle := remoteLifecycleForTest() | |
| 156 | 146 | cachedLifecycle := lifecycle | |
| 157 | 147 | 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 | + | })) | |
| 166 | 151 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 167 | 152 | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 168 | 153 | 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 ( | |||
| 12 | 12 | "github.com/bnema/vev/internal/ports" | |
| 13 | 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 14 | 14 | "github.com/bnema/vev/internal/protocol" | |
| 15 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 16 | 15 | "github.com/bnema/vev/internal/protocol/wire" | |
| 17 | 16 | "github.com/bnema/vev/internal/usecase/palette" | |
| 18 | 17 | ) | |
| 19 | 18 | ||
| 20 | 19 | 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))) | |
| 26 | 22 | sess, ac, sends := addRemoteRefreshPickerOwner(t, local, "local") | |
| 27 | 23 | effect := beginRecentRoutePaletteEffect(t, local, sess, ac) | |
| 28 | 24 | 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 @@ | |||
| 1 | 1 | package daemon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "context" | |
| 5 | 4 | "encoding/base64" | |
| 6 | - | "errors" | |
| 7 | 5 | "fmt" | |
| 8 | 6 | "math" | |
| 9 | - | "sort" | |
| 10 | 7 | "time" | |
| 11 | 8 | ||
| 12 | 9 | "github.com/bnema/vev/internal/domain" | |
| 10 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | 11 | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 14 | - | "github.com/bnema/vev/internal/usecase/palette" | |
| 15 | 12 | "github.com/bnema/vev/internal/usecase/picker" | |
| 16 | 13 | ) | |
| 17 | 14 | ||
| 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 | |
| 140 | 18 | ||
| 141 | 19 | func remoteSessionStateStopped(state catalogue.RemoteCatalogSessionState) bool { | |
| 142 | 20 | return state == catalogue.RemoteCatalogSessionDown | |
| @@ -177,9 +55,17 @@ func remoteCatalogSessionTarget(key domain.RemoteSessionKey, session catalogue.R | |||
| 177 | 55 | return key, target | |
| 178 | 56 | } | |
| 179 | 57 | ||
| 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 { | |
| 181 | 68 | key, target := remoteCatalogSessionTarget(key, session) | |
| 182 | - | availability := remotePickerAvailability(status) | |
| 183 | 69 | stopped := remoteSessionStateStopped(session.State) | |
| 184 | 70 | broken := session.State == catalogue.RemoteCatalogSessionBroken | |
| 185 | 71 | tabs := catalogue.CatalogTabs(session) | |
| @@ -206,14 +92,14 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal | |||
| 206 | 92 | // must remain possible, while sendRemoteAttachTargetForAttachment will | |
| 207 | 93 | // fail closed on the invalid target. | |
| 208 | 94 | remoteTarget := &target | |
| 209 | - | reason := remoteReasonForStatus(status) | |
| 95 | + | reason := directorySessionReason(host, session, target) | |
| 210 | 96 | targetValid := remoteTarget.Validate() == nil | |
| 211 | 97 | 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. | |
| 217 | 103 | if stopped { | |
| 218 | 104 | activation = picker.RemoteRestart | |
| 219 | 105 | } else { | |
| @@ -221,31 +107,14 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal | |||
| 221 | 107 | } | |
| 222 | 108 | } | |
| 223 | 109 | ||
| 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 | - | } | |
| 241 | 110 | return picker.SessionView{ | |
| 242 | 111 | ID: key.ID(), | |
| 243 | 112 | Name: key.Display(), | |
| 244 | 113 | RemoteKey: &key, | |
| 245 | 114 | RemoteTarget: remoteTarget, | |
| 246 | 115 | RemoteHost: key.Host, | |
| 247 | - | RemoteAvailability: availability, | |
| 248 | - | RemoteDetail: detail, | |
| 116 | + | RemoteAvailability: directoryPickerAvailability(host, now), | |
| 117 | + | RemoteDetail: directoryPickerDetail(host, session, reason, activation, now), | |
| 249 | 118 | RemoteReason: reason, | |
| 250 | 119 | RemoteActivation: activation, | |
| 251 | 120 | Tabs: viewTabs, | |
| @@ -255,424 +124,154 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal | |||
| 255 | 124 | } | |
| 256 | 125 | } | |
| 257 | 126 | ||
| 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 | |
| 299 | 133 | } | |
| 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 | |
| 317 | 136 | } | |
| 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 | |
| 346 | 141 | } | |
| 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 | |
| 347 | 151 | default: | |
| 348 | - | return false | |
| 152 | + | return domain.RemoteReasonRefreshing | |
| 349 | 153 | } | |
| 350 | - | d.remoteCatalog.mu.Lock() | |
| 351 | - | d.remoteCatalog.consumers[instance.ac] |= instance.kind | |
| 352 | - | d.remoteCatalog.mu.Unlock() | |
| 353 | - | return true | |
| 354 | 154 | } | |
| 355 | 155 | ||
| 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 | |
| 374 | 161 | } | |
| 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 | |
| 378 | 167 | 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 | |
| 444 | 170 | } | |
| 171 | + | return picker.RemoteStale | |
| 445 | 172 | } | |
| 446 | - | add(pinned) | |
| 447 | - | add(learned) | |
| 448 | - | return hosts, nil | |
| 449 | 173 | } | |
| 450 | 174 | ||
| 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…" | |
| 478 | 178 | } | |
| 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) | |
| 487 | 185 | } | |
| 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) | |
| 495 | 189 | } | |
| 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" | |
| 499 | 197 | 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…" | |
| 546 | 199 | } | |
| 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) | |
| 554 | 213 | } | |
| 555 | - | return generation | |
| 556 | 214 | } | |
| 557 | 215 | ||
| 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, | |
| 563 | 226 | } | |
| 564 | - | return false | |
| 565 | 227 | } | |
| 566 | 228 | ||
| 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, | |
| 592 | 239 | } | |
| 240 | + | } | |
| 593 | 241 | ||
| 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 | |
| 627 | 256 | } | |
| 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 | |
| 640 | 257 | } | |
| 641 | 258 | ||
| 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…" | |
| 654 | 262 | } | |
| 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…" | |
| 677 | 276 | } | |
| 678 | 277 | } | |
M internal/usecase/daemon/remote_picker_test.go +315 -674
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | "github.com/stretchr/testify/require" | |
| 16 | 16 | ||
| 17 | 17 | "github.com/bnema/vev/internal/domain" | |
| 18 | + | "github.com/bnema/vev/internal/ports" | |
| 18 | 19 | appports "github.com/bnema/vev/internal/ports" | |
| 19 | 20 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | |
| 20 | 21 | "github.com/bnema/vev/internal/protocol" | |
| @@ -57,8 +58,41 @@ func receiveRemotePickerClose(t *testing.T, ch <-chan struct{}, what string) { | |||
| 57 | 58 | require.NoError(t, waitRemotePickerClose(ch, what)) | |
| 58 | 59 | } | |
| 59 | 60 | ||
| 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 | + | } | |
| 62 | 96 | } | |
| 63 | 97 | ||
| 64 | 98 | func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) { | |
| @@ -68,7 +102,8 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) | |||
| 68 | 102 | LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionDown, | |
| 69 | 103 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, | |
| 70 | 104 | } | |
| 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)) | |
| 72 | 107 | require.True(t, view.Stopped) | |
| 73 | 108 | require.NotNil(t, view.RemoteTarget) | |
| 74 | 109 | require.True(t, view.RemoteTarget.Stopped) | |
| @@ -82,7 +117,7 @@ func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) { | |||
| 82 | 117 | tabs[0] = catalogue.RemoteCatalogTab{Name: "main"} | |
| 83 | 118 | view := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, catalogue.RemoteCatalogSession{ | |
| 84 | 119 | 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)) | |
| 86 | 121 | ||
| 87 | 122 | require.NotNil(t, view.RemoteTarget) | |
| 88 | 123 | require.Equal(t, uint16(math.MaxUint16), view.RemoteTarget.StoppedTab.ExpectedCount) | |
| @@ -90,30 +125,28 @@ func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) { | |||
| 90 | 125 | ||
| 91 | 126 | func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) { | |
| 92 | 127 | for _, attached := range []bool{false, true} { | |
| 93 | - | view := remotePickerView(domain.RemoteSessionKey{Host: "remote", Name: "work"}, catalogue.RemoteCatalogSession{ | |
| 128 | + | session := catalogue.RemoteCatalogSession{ | |
| 94 | 129 | LifecycleID: remoteLifecycleForTest(), Name: "work", State: "up", Attached: attached, | |
| 95 | 130 | 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()) | |
| 97 | 134 | require.Equal(t, "up", view.RemoteDetail) | |
| 98 | 135 | } | |
| 99 | 136 | } | |
| 100 | 137 | ||
| 101 | 138 | func TestRemotePickerScopesSameLifecycleBytesToEachEndpoint(t *testing.T) { | |
| 102 | - | store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}} | |
| 103 | - | d := newRemotePickerDaemon(store) | |
| 139 | + | d := newRemotePickerDaemon() | |
| 104 | 140 | lifecycle := remoteLifecycleForTest() | |
| 105 | 141 | session := catalogue.RemoteCatalogSession{ | |
| 106 | 142 | LifecycleID: lifecycle, Name: "work", State: "up", | |
| 107 | 143 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, | |
| 108 | 144 | } | |
| 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 | + | ) | |
| 117 | 150 | ||
| 118 | 151 | views, _ := d.pickerViews(nil, nil) | |
| 119 | 152 | var targets []*domain.RemoteSessionTarget | |
| @@ -127,52 +160,27 @@ func TestRemotePickerScopesSameLifecycleBytesToEachEndpoint(t *testing.T) { | |||
| 127 | 160 | require.Equal(t, "vev@remote", targets[1].Endpoint) | |
| 128 | 161 | } | |
| 129 | 162 | ||
| 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) { | |
| 131 | 167 | now := time.Unix(100, 0) | |
| 132 | - | clock := &remotePickerClock{now: now} | |
| 133 | - | d := newTestDaemon(t, nil, clock) | |
| 168 | + | d := newTestDaemon(t, nil, &remotePickerClock{now: now}) | |
| 134 | 169 | 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 | + | ||
| 147 | 177 | 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") | |
| 150 | 179 | ||
| 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) | |
| 176 | 184 | } | |
| 177 | 185 | ||
| 178 | 186 | func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) { | |
| @@ -216,20 +224,16 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) { | |||
| 216 | 224 | ||
| 217 | 225 | for _, test := range tests { | |
| 218 | 226 | t.Run(test.name, func(t *testing.T) { | |
| 219 | - | d := newRemotePickerDaemon(nil) | |
| 227 | + | d := newRemotePickerDaemon() | |
| 220 | 228 | 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)) | |
| 233 | 237 | ||
| 234 | 238 | views, _ := d.pickerViews(nil, nil) | |
| 235 | 239 | require.Len(t, views, 1) | |
| @@ -247,69 +251,100 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) { | |||
| 247 | 251 | } | |
| 248 | 252 | ||
| 249 | 253 | 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 | + | } | |
| 250 | 258 | 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 | |
| 254 | 263 | }{ | |
| 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 | + | }, | |
| 258 | 289 | } | |
| 259 | 290 | ||
| 260 | 291 | for _, test := range tests { | |
| 261 | 292 | 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) | |
| 269 | 295 | ||
| 270 | 296 | views, _ := d.pickerViews(nil, nil) | |
| 271 | 297 | ||
| 272 | 298 | require.Len(t, views, 1) | |
| 273 | 299 | 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 | + | } | |
| 274 | 305 | }) | |
| 275 | 306 | } | |
| 276 | 307 | } | |
| 277 | 308 | ||
| 278 | 309 | func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) { | |
| 279 | - | d := newRemotePickerDaemon(nil) | |
| 280 | 310 | 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 | + | }) | |
| 289 | 318 | ||
| 290 | 319 | views, _ := d.pickerViews(nil, nil) | |
| 291 | 320 | ||
| 292 | 321 | require.Len(t, views, 1) | |
| 293 | 322 | 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") | |
| 295 | 323 | } | |
| 296 | 324 | ||
| 297 | - | func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) { | |
| 325 | + | func TestRemotePickerNoSessionHostFailuresRemainVisible(t *testing.T) { | |
| 298 | 326 | for _, test := range []struct { | |
| 299 | 327 | name string | |
| 300 | - | status remoteHostStatus | |
| 328 | + | host ports.RemoteHostSnapshot | |
| 301 | 329 | want picker.RemoteAvailability | |
| 302 | 330 | wantDetail string | |
| 303 | 331 | }{ | |
| 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 | + | }, | |
| 306 | 344 | } { | |
| 307 | 345 | 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) | |
| 313 | 348 | ||
| 314 | 349 | views, _ := d.pickerViews(nil, nil) | |
| 315 | 350 | ||
| @@ -324,102 +359,90 @@ func TestRemotePickerNoCacheHostFailuresRemainVisible(t *testing.T) { | |||
| 324 | 359 | } | |
| 325 | 360 | } | |
| 326 | 361 | ||
| 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 | + | } | |
| 336 | 395 | } | |
| 337 | 396 | ||
| 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") | |
| 343 | 407 | ||
| 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) | |
| 351 | 412 | ||
| 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") | |
| 356 | 415 | } | |
| 357 | 416 | ||
| 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 | |
| 361 | 423 | } | |
| 362 | 424 | ||
| 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() | |
| 369 | 430 | } | |
| 370 | 431 | ||
| 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)} | |
| 392 | 434 | } | |
| 393 | 435 | ||
| 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++ | |
| 400 | 440 | } | |
| 401 | 441 | ||
| 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++ | |
| 423 | 446 | } | |
| 424 | 447 | ||
| 425 | 448 | type fixedRemoteRefreshClock struct{ now time.Time } | |
| @@ -444,20 +467,6 @@ func (c *remotePickerClock) Advance(d time.Duration) { | |||
| 444 | 467 | c.mu.Unlock() | |
| 445 | 468 | } | |
| 446 | 469 | ||
| 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 | - | ||
| 461 | 470 | func remoteCatalogForTest(sessions ...catalogue.RemoteCatalogSession) catalogue.RemoteCatalog { | |
| 462 | 471 | return catalogue.RemoteCatalog{ | |
| 463 | 472 | ProtocolVersion: protocol.Version, | |
| @@ -466,213 +475,9 @@ func remoteCatalogForTest(sessions ...catalogue.RemoteCatalogSession) catalogue. | |||
| 466 | 475 | } | |
| 467 | 476 | } | |
| 468 | 477 | ||
| 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) { | |
| 470 | 479 | 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...)) | |
| 676 | 481 | } | |
| 677 | 482 | ||
| 678 | 483 | func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) { | |
| @@ -681,13 +486,8 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) { | |||
| 681 | 486 | LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, | |
| 682 | 487 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}}, | |
| 683 | 488 | } | |
| 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) | |
| 691 | 491 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 692 | 492 | token := sess.captureAttachmentCapability(ac, ac.transport()) | |
| 693 | 493 | effect, admitted := ac.beginAttachmentEffect(token) | |
| @@ -720,7 +520,8 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) { | |||
| 720 | 520 | }, | |
| 721 | 521 | } | |
| 722 | 522 | 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)) | |
| 724 | 525 | model := picker.New([]picker.SessionView{ | |
| 725 | 526 | {ID: "local", Name: "local", Tabs: []picker.TabEntry{{TabID: "local-tab", Name: "local"}}}, | |
| 726 | 527 | remoteView, | |
| @@ -738,11 +539,8 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) { | |||
| 738 | 539 | require.True(t, selected.RemoteTarget.Stopped) | |
| 739 | 540 | require.Equal(t, domain.NewStableTabSelector("tab-b"), selected.RemoteTarget.StoppedTab) | |
| 740 | 541 | ||
| 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) | |
| 746 | 544 | localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local") | |
| 747 | 545 | token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport()) | |
| 748 | 546 | effect, admitted := localAttachment.beginAttachmentEffect(token) | |
| @@ -787,7 +585,8 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing | |||
| 787 | 585 | Tabs: []catalogue.RemoteCatalogTab{}, | |
| 788 | 586 | } | |
| 789 | 587 | 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)) | |
| 791 | 590 | require.Equal(t, picker.RemoteRestart, remoteView.RemoteActivation) | |
| 792 | 591 | ||
| 793 | 592 | model := picker.New([]picker.SessionView{ | |
| @@ -807,13 +606,8 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing | |||
| 807 | 606 | require.True(t, selected.RemoteTarget.Stopped) | |
| 808 | 607 | require.Equal(t, domain.TabSelector{}, selected.RemoteTarget.StoppedTab) | |
| 809 | 608 | ||
| 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) | |
| 817 | 611 | localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local") | |
| 818 | 612 | token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport()) | |
| 819 | 613 | effect, admitted := localAttachment.beginAttachmentEffect(token) | |
| @@ -853,7 +647,7 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) { | |||
| 853 | 647 | ||
| 854 | 648 | for _, tt := range tests { | |
| 855 | 649 | t.Run(tt.name, func(t *testing.T) { | |
| 856 | - | d := newRemotePickerDaemon(nil) | |
| 650 | + | d := newRemotePickerDaemon() | |
| 857 | 651 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | |
| 858 | 652 | if tt.action == protocol.NavigationOpenHomePicker { | |
| 859 | 653 | ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker | |
| @@ -883,13 +677,8 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) { | |||
| 883 | 677 | LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, | |
| 884 | 678 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}}, | |
| 885 | 679 | } | |
| 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) | |
| 893 | 682 | cause := errors.New("remote attach send failed") | |
| 894 | 683 | tr := portsmocks.NewMockServerConnection(t) | |
| 895 | 684 | tr.EXPECT().SendServer(mock.Anything).Return(cause) | |
| @@ -943,49 +732,6 @@ func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID, t | |||
| 943 | 732 | return sess, ac, sends | |
| 944 | 733 | } | |
| 945 | 734 | ||
| 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 | - | ||
| 989 | 735 | func TestRemotePickerTeardownLifecycle(t *testing.T) { | |
| 990 | 736 | for _, test := range []struct { | |
| 991 | 737 | name string | |
| @@ -998,7 +744,7 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) { | |||
| 998 | 744 | {name: "send error removes non-resumable attachment", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }}, | |
| 999 | 745 | } { | |
| 1000 | 746 | t.Run(test.name, func(t *testing.T) { | |
| 1001 | - | d := newRemotePickerDaemon(nil) | |
| 747 | + | d := newRemotePickerDaemon() | |
| 1002 | 748 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 1003 | 749 | ac.resumeCapable = test.resumeCapable | |
| 1004 | 750 | model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | |
| @@ -1009,19 +755,14 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) { | |||
| 1009 | 755 | ||
| 1010 | 756 | test.teardown(d, sess, ac) | |
| 1011 | 757 | ||
| 1012 | - | d.remoteCatalog.mu.Lock() | |
| 1013 | - | _, registered := d.remoteCatalog.consumers[ac] | |
| 1014 | - | d.remoteCatalog.mu.Unlock() | |
| 1015 | 758 | ac.overlays.pickerMu.Lock() | |
| 1016 | 759 | current, currentGeneration := ac.overlays.picker, ac.overlays.pickerGeneration | |
| 1017 | 760 | ac.overlays.pickerMu.Unlock() | |
| 1018 | 761 | if !test.resumeCapable { | |
| 1019 | - | require.False(t, registered) | |
| 1020 | 762 | require.Nil(t, current) | |
| 1021 | 763 | return | |
| 1022 | 764 | } | |
| 1023 | 765 | require.True(t, ac.parked) | |
| 1024 | - | require.True(t, registered) | |
| 1025 | 766 | require.Same(t, model, current) | |
| 1026 | 767 | require.Equal(t, generation, currentGeneration) | |
| 1027 | 768 | d.mu.Lock() | |
| @@ -1033,37 +774,102 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) { | |||
| 1033 | 774 | } | |
| 1034 | 775 | } | |
| 1035 | 776 | ||
| 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{} | |
| 1041 | 786 | 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") | |
| 1048 | 787 | ||
| 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 | + | } | |
| 1053 | 799 | ||
| 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. | |
| 1054 | 823 | 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() | |
| 1056 | 832 | 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 | + | } | |
| 1063 | 869 | } | |
| 1064 | 870 | ||
| 1065 | 871 | func TestParkedPickerRetirementClosesCapturedGenerationOnly(t *testing.T) { | |
| 1066 | - | d := newRemotePickerDaemon(nil) | |
| 872 | + | d := newRemotePickerDaemon() | |
| 1067 | 873 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 1068 | 874 | ac.resumeCapable = true | |
| 1069 | 875 | d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| @@ -1089,7 +895,7 @@ func TestParkedPickerRetirementClosesCapturedGenerationOnly(t *testing.T) { | |||
| 1089 | 895 | } | |
| 1090 | 896 | ||
| 1091 | 897 | func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) { | |
| 1092 | - | d := newRemotePickerDaemon(nil) | |
| 898 | + | d := newRemotePickerDaemon() | |
| 1093 | 899 | firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first") | |
| 1094 | 900 | secondSession, second, _ := addRemoteRefreshPickerOwner(t, d, "second") | |
| 1095 | 901 | first.resumeCapable, second.resumeCapable = true, true | |
| @@ -1109,7 +915,7 @@ func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) { | |||
| 1109 | 915 | } | |
| 1110 | 916 | ||
| 1111 | 917 | func TestParkedPickerResumePreservesGeneration(t *testing.T) { | |
| 1112 | - | d := newRemotePickerDaemon(nil) | |
| 918 | + | d := newRemotePickerDaemon() | |
| 1113 | 919 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 1114 | 920 | ac.resumeCapable = true | |
| 1115 | 921 | ac.clientID = [16]byte{1, 2, 3, 4} | |
| @@ -1132,7 +938,7 @@ func TestParkedPickerResumePreservesGeneration(t *testing.T) { | |||
| 1132 | 938 | } | |
| 1133 | 939 | ||
| 1134 | 940 | func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) { | |
| 1135 | - | d := newRemotePickerDaemon(nil) | |
| 941 | + | d := newRemotePickerDaemon() | |
| 1136 | 942 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | |
| 1137 | 943 | ac.resumeCapable = true | |
| 1138 | 944 | d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | |
| @@ -1150,168 +956,3 @@ func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) { | |||
| 1150 | 956 | require.Same(t, newer, ac.overlays.picker) | |
| 1151 | 957 | ac.overlays.pickerMu.Unlock() | |
| 1152 | 958 | } | |
| 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 { | |||
| 453 | 453 | remote := session.RemoteKey != nil || session.RemoteTarget != nil || session.RemoteHost != "" | |
| 454 | 454 | if remote { | |
| 455 | 455 | switch session.RemoteReason { | |
| 456 | - | case "host_unreachable": | |
| 456 | + | case domain.RemoteReasonHostUnreachable: | |
| 457 | 457 | return rowStatusDown | |
| 458 | - | case "version_mismatch": | |
| 458 | + | case domain.RemoteReasonVersionMismatch: | |
| 459 | 459 | return rowStatusVersion | |
| 460 | - | case "refreshing", "catalog_stale": | |
| 460 | + | case domain.RemoteReasonRefreshing, domain.RemoteReasonCatalogStale: | |
| 461 | 461 | return rowStatusStale | |
| 462 | - | case "malformed", "session_broken", "identity_changed": | |
| 462 | + | case domain.RemoteReasonMalformed, domain.RemoteReasonSessionBroken, domain.RemoteReasonIdentityChanged, domain.RemoteReasonAuthFailure: | |
| 463 | 463 | return rowStatusError | |
| 464 | 464 | } | |
| 465 | 465 | } | |
M internal/usecase/picker/model_test.go +1 -1
| @@ -842,7 +842,7 @@ func TestUnavailableStoppedRemoteUsesAvailabilityBadgeAndHint(t *testing.T) { | |||
| 842 | 842 | lifecycle := domain.SessionLifecycleID{1} | |
| 843 | 843 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | |
| 844 | 844 | 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}) | |
| 846 | 846 | ||
| 847 | 847 | frame := m.Render(domain.Size{Cols: 40, Rows: 3}, Preview{}) | |
| 848 | 848 | 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 @@ | |||
| 1 | 1 | # syntax=docker/dockerfile:1 | |
| 2 | - | FROM golang:1.26 AS build | |
| 2 | + | FROM golang:1.27 AS build | |
| 3 | 3 | WORKDIR /src | |
| 4 | 4 | COPY go.mod go.sum ./ | |
| 5 | 5 | RUN go mod download | |