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 | {"usecase accepts semantic protocol", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol", false, true}, | 72 | {"usecase accepts semantic protocol", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol", false, true}, | ||
| 73 | {"usecase rejects wire", modulePath + "/internal/usecase/daemon", modulePath + "/internal/protocol/wire", false, false}, | 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 | {"usecase rejects adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/ipc", false, false}, | 80 | {"usecase rejects adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/ipc", false, false}, | ||
| 75 | {"domain rejects ports", modulePath + "/internal/domain", modulePath + "/internal/ports", false, false}, | 81 | {"domain rejects ports", modulePath + "/internal/domain", modulePath + "/internal/ports", false, false}, | ||
| 76 | {"pkg rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", false, false}, | 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 | "fmt" | 8 | "fmt" | ||
| 9 | "io" | 9 | "io" | ||
| 10 | "log/slog" | 10 | "log/slog" | ||
| 11 | + | "os" | |||
| 11 | "os/exec" | 12 | "os/exec" | ||
| 12 | "strings" | 13 | "strings" | ||
| 13 | "time" | 14 | "time" | ||
| @@ -24,7 +25,8 @@ import ( | |||||
| 24 | const ( | 25 | const ( | ||
| 25 | maxCatalogBytes = 1 << 20 | 26 | maxCatalogBytes = 1 << 20 | ||
| 26 | maxCatalogDiagnosticBytes = 4 << 10 | 27 | maxCatalogDiagnosticBytes = 4 << 10 | ||
| 27 | - | catalogCommandTimeout = 30 * time.Second | 28 | + | catalogCommandTimeout = 10 * time.Second |
| 29 | + | catalogSSHConnectTimeout = 5 * time.Second | |||
| 28 | catalogCommandWaitDelay = 500 * time.Millisecond | 30 | catalogCommandWaitDelay = 500 * time.Millisecond | ||
| 29 | ) | 31 | ) | ||
| 30 | 32 | ||||
| @@ -36,6 +38,131 @@ var ( | |||||
| 36 | errCatalogTooLarge = errors.New("remote catalog: output exceeds size limit") | 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 | // CatalogClient fetches remote session catalogs over SSH. | 166 | // CatalogClient fetches remote session catalogs over SSH. | ||
| 40 | type CatalogClient struct { | 167 | type CatalogClient struct { | ||
| 41 | command func(ctx context.Context, name string, args ...string) *exec.Cmd | 168 | command func(ctx context.Context, name string, args ...string) *exec.Cmd | ||
| @@ -57,10 +184,13 @@ func (c *CatalogClient) listTimeout() time.Duration { | |||||
| 57 | return catalogCommandTimeout | 184 | return catalogCommandTimeout | ||
| 58 | } | 185 | } | ||
| 59 | 186 | ||||
| 60 | - | // List runs `ssh -- <target> 'vev' 'cmd' 'remote-catalog' '--json'` and decodes | 187 | + | // List runs a non-interactive `ssh` observation of `vev cmd remote-catalog |
| 61 | - | // exactly one versioned catalog envelope from stdout. List always applies a | 188 | + | // --json` and decodes exactly one versioned catalog envelope from stdout. |
| 62 | - | // bounded command timeout derived from ctx so a caller with no deadline cannot | 189 | + | // The observation never prompts, allocates a TTY, mutates trust, or executes |
| 63 | - | // hang indefinitely, while still honoring caller cancellation. | 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 | func (c *CatalogClient) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) { | 194 | func (c *CatalogClient) List(ctx context.Context, target string) (catalogue.RemoteCatalog, error) { | ||
| 65 | if err := ctx.Err(); err != nil { | 195 | if err := ctx.Err(); err != nil { | ||
| 66 | return catalogue.RemoteCatalog{}, err | 196 | return catalogue.RemoteCatalog{}, err | ||
| @@ -77,8 +207,10 @@ func (c *CatalogClient) List(ctx context.Context, target string) (catalogue.Remo | |||||
| 77 | if command == nil { | 207 | if command == nil { | ||
| 78 | command = exec.CommandContext | 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 | cmd := command(runCtx, spec.Path, spec.Args...) | 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 | stdout := boundedBuffer{limit: maxCatalogBytes} | 214 | stdout := boundedBuffer{limit: maxCatalogBytes} | ||
| 83 | stderr := boundedBuffer{limit: maxCatalogDiagnosticBytes} | 215 | stderr := boundedBuffer{limit: maxCatalogDiagnosticBytes} | ||
| 84 | cmd.Stdout = &stdout | 216 | cmd.Stdout = &stdout | ||
M internal/adapters/remote/catalog_cache.go +29 -6
| @@ -2,6 +2,7 @@ package remote | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "encoding/hex" | |||
| 5 | "encoding/json" | 6 | "encoding/json" | ||
| 6 | "errors" | 7 | "errors" | ||
| 7 | "fmt" | 8 | "fmt" | ||
| @@ -17,9 +18,10 @@ import ( | |||||
| 17 | ) | 18 | ) | ||
| 18 | 19 | ||||
| 19 | const ( | 20 | const ( | ||
| 20 | - | catalogCacheFileName = "remote-catalog-cache.json" | 21 | + | catalogCacheFileName = "remote-catalog-cache.json" |
| 21 | - | catalogCacheExactFileVersion = 2 | 22 | + | catalogCacheExactFileVersion = 2 |
| 22 | - | catalogCacheFileVersion = 3 | 23 | + | catalogCacheLegacyFileVersion = 3 |
| 24 | + | catalogCacheFileVersion = 4 | |||
| 23 | ) | 25 | ) | ||
| 24 | 26 | ||||
| 25 | // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir. | 27 | // CatalogCachePath returns the canonical location of the remote catalog cache in stateDir. | ||
| @@ -37,6 +39,7 @@ type catalogCacheVersion struct { | |||||
| 37 | type catalogCacheHost struct { | 39 | type catalogCacheHost struct { | ||
| 38 | Target *string `json:"target"` | 40 | Target *string `json:"target"` | ||
| 39 | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | 41 | FetchedAtUnixNano *int64 `json:"fetched_at_unix_nano"` | ||
| 42 | + | Incarnation *string `json:"incarnation,omitempty"` | |||
| 40 | Sessions *[]catalogCacheSession `json:"sessions"` | 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 | var file catalogCacheFile | 114 | var file catalogCacheFile | ||
| 115 | + | requireIncarnation := false | |||
| 112 | switch *version.Version { | 116 | switch *version.Version { | ||
| 113 | case catalogCacheFileVersion: | 117 | case catalogCacheFileVersion: | ||
| 118 | + | requireIncarnation = true | |||
| 119 | + | if err := decodeCatalogCacheFile(raw, true, &file); err != nil { | |||
| 120 | + | return nil, err | |||
| 121 | + | } | |||
| 122 | + | case catalogCacheLegacyFileVersion: | |||
| 114 | if err := decodeCatalogCacheFile(raw, true, &file); err != nil { | 123 | if err := decodeCatalogCacheFile(raw, true, &file); err != nil { | ||
| 115 | return nil, err | 124 | return nil, err | ||
| 116 | } | 125 | } | ||
| @@ -147,6 +156,17 @@ func (c *fileCatalogCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { | |||||
| 147 | FetchedAt: time.Unix(0, *host.FetchedAtUnixNano), | 156 | FetchedAt: time.Unix(0, *host.FetchedAtUnixNano), | ||
| 148 | Sessions: make([]catalogue.RemoteCatalogSession, 0, len(*host.Sessions)), | 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 | for _, session := range *host.Sessions { | 170 | for _, session := range *host.Sessions { | ||
| 151 | if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil { | 171 | if session.LifecycleID == nil || session.Name == nil || session.State == nil || session.Ephemeral == nil || session.Attached == nil || session.Tabs == nil { | ||
| 152 | return nil, fmt.Errorf("remote catalog cache: malformed cache file: missing session fields") | 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 | for _, entry := range normalized { | 258 | for _, entry := range normalized { | ||
| 239 | target := entry.Host | 259 | target := entry.Host | ||
| 240 | fetchedAt := entry.FetchedAt.UnixNano() | 260 | fetchedAt := entry.FetchedAt.UnixNano() | ||
| 261 | + | incarnation := hex.EncodeToString(entry.Incarnation[:]) | |||
| 241 | sessions := make([]catalogCacheSession, 0, len(entry.Sessions)) | 262 | sessions := make([]catalogCacheSession, 0, len(entry.Sessions)) | ||
| 242 | for _, session := range entry.Sessions { | 263 | for _, session := range entry.Sessions { | ||
| 243 | name := session.Name | 264 | name := session.Name | ||
| @@ -261,6 +282,7 @@ func (c *fileCatalogCache) Store(entries []catalogue.RemoteCatalogCacheEntry) er | |||||
| 261 | hosts = append(hosts, catalogCacheHost{ | 282 | hosts = append(hosts, catalogCacheHost{ | ||
| 262 | Target: &target, | 283 | Target: &target, | ||
| 263 | FetchedAtUnixNano: &fetchedAt, | 284 | FetchedAtUnixNano: &fetchedAt, | ||
| 285 | + | Incarnation: &incarnation, | |||
| 264 | Sessions: &sessions, | 286 | Sessions: &sessions, | ||
| 265 | }) | 287 | }) | ||
| 266 | } | 288 | } | ||
| @@ -291,9 +313,10 @@ func normalizeCatalogCacheEntries(entries []catalogue.RemoteCatalogCacheEntry) ( | |||||
| 291 | hosts[entry.Host] = struct{}{} | 313 | hosts[entry.Host] = struct{}{} | ||
| 292 | 314 | ||||
| 293 | copyEntry := catalogue.RemoteCatalogCacheEntry{ | 315 | copyEntry := catalogue.RemoteCatalogCacheEntry{ | ||
| 294 | - | Host: entry.Host, | 316 | + | Host: entry.Host, |
| 295 | - | FetchedAt: entry.FetchedAt, | 317 | + | FetchedAt: entry.FetchedAt, |
| 296 | - | Sessions: make([]catalogue.RemoteCatalogSession, 0, len(entry.Sessions)), | 318 | + | Incarnation: entry.Incarnation, |
| 319 | + | Sessions: make([]catalogue.RemoteCatalogSession, 0, len(entry.Sessions)), | |||
| 297 | } | 320 | } | ||
| 298 | sessions := make(map[string]struct{}, len(entry.Sessions)) | 321 | sessions := make(map[string]struct{}, len(entry.Sessions)) | ||
| 299 | for _, session := range entry.Sessions { | 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 | fetchedAt := time.Unix(0, 1780000000000000000) | 29 | fetchedAt := time.Unix(0, 1780000000000000000) | ||
| 30 | entries := []catalogue.RemoteCatalogCacheEntry{ | 30 | entries := []catalogue.RemoteCatalogCacheEntry{ | ||
| 31 | { | 31 | { | ||
| 32 | - | Host: "zebra", | 32 | + | Host: "zebra", |
| 33 | - | FetchedAt: fetchedAt, | 33 | + | FetchedAt: fetchedAt, |
| 34 | + | Incarnation: [16]byte{9}, | |||
| 34 | Sessions: []catalogue.RemoteCatalogSession{ | 35 | Sessions: []catalogue.RemoteCatalogSession{ | ||
| 35 | { | 36 | { | ||
| 36 | LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2", | 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 | + | Host: "arch", |
| 44 | - | FetchedAt: fetchedAt.Add(time.Second), | 45 | + | FetchedAt: fetchedAt.Add(time.Second), |
| 45 | - | Sessions: []catalogue.RemoteCatalogSession{}, | 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 | if alpha < 0 || work < 0 || alpha >= work { | 65 | if alpha < 0 || work < 0 || alpha >= work { | ||
| 64 | t.Fatalf("stored sessions are not ordered: %s", raw) | 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"`) { | 68 | + | if !strings.Contains(string(raw), `"version":4`) || !strings.Contains(string(raw), `"tabs":[{"id":"work-1"`) { |
| 67 | - | t.Fatalf("stored cache does not pin the v3 typed-tab contract: %s", raw) | 69 | + | t.Fatalf("stored cache does not pin the v4 typed-tab contract: %s", raw) |
| 68 | } | 70 | } | ||
| 69 | if strings.Contains(string(raw), `"detail"`) || strings.Contains(string(raw), `"attention"`) { | 71 | if strings.Contains(string(raw), `"detail"`) || strings.Contains(string(raw), `"attention"`) { | ||
| 70 | t.Fatalf("stored cache contains dynamic tab fields: %s", raw) | 72 | t.Fatalf("stored cache contains dynamic tab fields: %s", raw) | ||
| @@ -75,13 +77,15 @@ func TestCatalogCacheStoreLoad(t *testing.T) { | |||||
| 75 | } | 77 | } | ||
| 76 | want := []catalogue.RemoteCatalogCacheEntry{ | 78 | want := []catalogue.RemoteCatalogCacheEntry{ | ||
| 77 | { | 79 | { | ||
| 78 | - | Host: "arch", | 80 | + | Host: "arch", |
| 79 | - | FetchedAt: fetchedAt.Add(time.Second), | 81 | + | FetchedAt: fetchedAt.Add(time.Second), |
| 80 | - | Sessions: []catalogue.RemoteCatalogSession{}, | 82 | + | Incarnation: [16]byte{7}, |
| 83 | + | Sessions: []catalogue.RemoteCatalogSession{}, | |||
| 81 | }, | 84 | }, | ||
| 82 | { | 85 | { | ||
| 83 | - | Host: "zebra", | 86 | + | Host: "zebra", |
| 84 | - | FetchedAt: fetchedAt, | 87 | + | FetchedAt: fetchedAt, |
| 88 | + | Incarnation: [16]byte{9}, | |||
| 85 | Sessions: []catalogue.RemoteCatalogSession{ | 89 | Sessions: []catalogue.RemoteCatalogSession{ | ||
| 86 | {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true}, | 90 | {LifecycleID: [16]byte{2}, Name: "alpha", State: "down", Ephemeral: true, Tabs: []catalogue.RemoteCatalogTab{{ID: "alpha-1"}}, Attached: true}, | ||
| 87 | {LifecycleID: [16]byte{1}, Name: "work", State: "up", LastUsedSeq: 42, ActiveTabID: "work-2", Tabs: []catalogue.RemoteCatalogTab{{ID: "work-1"}, {ID: "work-2", Index: 1}}}, | 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 | func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { | 187 | func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { | ||
| 160 | t.Parallel() | 188 | t.Parallel() | ||
| 161 | 189 | ||||
| @@ -166,7 +194,10 @@ func TestCatalogCacheLoadRejectsInvalidFilesWithoutReplacingThem(t *testing.T) { | |||||
| 166 | {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)}, | 194 | {name: "truncated", raw: []byte(`{"version":3,"hosts":[`)}, | ||
| 167 | {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)}, | 195 | {name: "trailing JSON", raw: []byte(`{"version":3,"hosts":[]} {}`)}, | ||
| 168 | {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}]}]}`)}, | 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 | {name: "missing hosts", raw: []byte(`{"version":3}`)}, | 201 | {name: "missing hosts", raw: []byte(`{"version":3}`)}, | ||
| 171 | {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)}, | 202 | {name: "null hosts", raw: []byte(`{"version":3,"hosts":null}`)}, | ||
| 172 | {name: "null sessions", raw: []byte(`{"version":3,"hosts":[{"target":"arch","fetched_at_unix_nano":1,"sessions":null}]}`)}, | 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 | func equalRemoteCatalogCacheEntry(a, b catalogue.RemoteCatalogCacheEntry) bool { | 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 | "time" | 12 | "time" | ||
| 13 | 13 | ||||
| 14 | "github.com/bnema/vev/internal/adapters/sshstdio" | 14 | "github.com/bnema/vev/internal/adapters/sshstdio" | ||
| 15 | + | "github.com/bnema/vev/internal/domain" | |||
| 15 | "github.com/bnema/vev/internal/protocol" | 16 | "github.com/bnema/vev/internal/protocol" | ||
| 16 | "github.com/bnema/vev/internal/protocol/catalogue" | 17 | "github.com/bnema/vev/internal/protocol/catalogue" | ||
| 17 | ) | 18 | ) | ||
| @@ -49,7 +50,7 @@ func TestRemoteCatalogClientCommandConstruction(t *testing.T) { | |||||
| 49 | t.Fatalf("List() error = %v", err) | 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 | if gotPath != want.Path { | 54 | if gotPath != want.Path { | ||
| 54 | t.Fatalf("Path = %q, want %q", gotPath, want.Path) | 55 | t.Fatalf("Path = %q, want %q", gotPath, want.Path) | ||
| 55 | } | 56 | } | ||
| @@ -61,11 +62,24 @@ func TestRemoteCatalogClientCommandConstruction(t *testing.T) { | |||||
| 61 | t.Fatalf("Args[%d] = %q, want %q (all %q)", i, gotArgs[i], want.Args[i], gotArgs) | 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 | + | sep := -1 |
| 65 | - | t.Fatalf("target must remain one unquoted local argv word after --; got %q", gotArgs) | 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'") { | 81 | + | if sep+2 >= len(gotArgs) || !strings.Contains(gotArgs[sep+2], "'vev'") || !strings.Contains(gotArgs[sep+2], "'remote-catalog'") { |
| 68 | - | t.Fatalf("remote command words must be shell-quoted; got %q", gotArgs[2]) | 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 | func TestRemoteCatalogClientContextCanceled(t *testing.T) { | 344 | func TestRemoteCatalogClientContextCanceled(t *testing.T) { | ||
| 277 | t.Parallel() | 345 | t.Parallel() | ||
| 278 | ctx, cancel := context.WithCancel(context.Background()) | 346 | ctx, cancel := context.WithCancel(context.Background()) | ||
M internal/adapters/remote/host_registry.go +271 -78
| @@ -1,13 +1,14 @@ | |||||
| 1 | package remote | 1 | package remote | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "crypto/rand" | |||
| 5 | + | "encoding/hex" | |||
| 4 | "encoding/json" | 6 | "encoding/json" | ||
| 5 | "errors" | 7 | "errors" | ||
| 6 | "fmt" | 8 | "fmt" | ||
| 7 | "log/slog" | 9 | "log/slog" | ||
| 8 | "os" | 10 | "os" | ||
| 9 | "path/filepath" | 11 | "path/filepath" | ||
| 10 | - | "slices" | |||
| 11 | "sort" | 12 | "sort" | ||
| 12 | "syscall" | 13 | "syscall" | ||
| 13 | "unicode/utf8" | 14 | "unicode/utf8" | ||
| @@ -18,27 +19,56 @@ import ( | |||||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | const ( | 21 | const ( | ||
| 21 | - | hostsFileName = "hosts.json" | 22 | + | hostsFileName = "hosts.json" |
| 22 | - | hostsFileVersion = 2 | 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 | // HostStorePath returns the canonical location of the remote host store in stateDir. | 40 | // HostStorePath returns the canonical location of the remote host store in stateDir. | ||
| 26 | func HostStorePath(stateDir string) string { return filepath.Join(stateDir, hostsFileName) } | 41 | func HostStorePath(stateDir string) string { return filepath.Join(stateDir, hostsFileName) } | ||
| 27 | 42 | ||||
| 28 | type hostsFile struct { | 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 | Version int `json:"version"` | 59 | Version int `json:"version"` | ||
| 30 | Pinned []string `json:"pinned"` | 60 | Pinned []string `json:"pinned"` | ||
| 31 | Learned []string `json:"learned"` | 61 | Learned []string `json:"learned"` | ||
| 32 | } | 62 | } | ||
| 33 | 63 | ||||
| 34 | - | type legacyHostsFile struct { | 64 | + | type legacyListHostsFile struct { |
| 35 | Version int `json:"version"` | 65 | Version int `json:"version"` | ||
| 36 | Hosts []string `json:"hosts"` | 66 | Hosts []string `json:"hosts"` | ||
| 37 | } | 67 | } | ||
| 38 | 68 | ||||
| 39 | type hostState struct { | 69 | type hostState struct { | ||
| 40 | - | pinned []string | 70 | + | pinned []domain.RemoteRegistration |
| 41 | - | learned map[string]struct{} | 71 | + | learned map[string]domain.RemoteRegistration |
| 42 | } | 72 | } | ||
| 43 | 73 | ||||
| 44 | type fileHostStore struct { | 74 | type fileHostStore struct { | ||
| @@ -52,14 +82,14 @@ func NewFileHostStore(path string) ports.RemoteHostStore { | |||||
| 52 | 82 | ||||
| 53 | var _ ports.RemoteHostStore = (*fileHostStore)(nil) | 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 | err = s.withLock(func() error { | 86 | err = s.withLock(func() error { | ||
| 57 | - | state, loadErr := s.loadLocked() | 87 | + | state, loadErr := s.loadMigratedLocked() |
| 58 | if loadErr != nil { | 88 | if loadErr != nil { | ||
| 59 | return loadErr | 89 | return loadErr | ||
| 60 | } | 90 | } | ||
| 61 | - | pinned = append([]string(nil), state.pinned...) | 91 | + | pinned = append([]domain.RemoteRegistration(nil), state.pinned...) |
| 62 | - | learned = sortedHosts(state.learned) | 92 | + | learned = sortedRegistrations(state.learned) |
| 63 | return nil | 93 | return nil | ||
| 64 | }) | 94 | }) | ||
| 65 | if err != nil { | 95 | if err != nil { | ||
| @@ -74,14 +104,25 @@ func (s *fileHostStore) AddPinned(target string) error { | |||||
| 74 | return err | 104 | return err | ||
| 75 | } | 105 | } | ||
| 76 | err := s.withLock(func() error { | 106 | err := s.withLock(func() error { | ||
| 77 | - | state, err := s.loadLocked() | 107 | + | state, err := s.loadMigratedLocked() |
| 78 | if err != nil { | 108 | if err != nil { | ||
| 79 | return err | 109 | return err | ||
| 80 | } | 110 | } | ||
| 81 | - | if slices.Contains(state.pinned, target) { | 111 | + | for _, record := range state.pinned { |
| 82 | - | return nil | 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 | return s.saveLocked(state) | 126 | return s.saveLocked(state) | ||
| 86 | }) | 127 | }) | ||
| 87 | if err != nil { | 128 | if err != nil { | ||
| @@ -95,11 +136,11 @@ func (s *fileHostStore) RemovePinned(target string) error { | |||||
| 95 | return err | 136 | return err | ||
| 96 | } | 137 | } | ||
| 97 | err := s.withLock(func() error { | 138 | err := s.withLock(func() error { | ||
| 98 | - | state, err := s.loadLocked() | 139 | + | state, err := s.loadMigratedLocked() |
| 99 | if err != nil { | 140 | if err != nil { | ||
| 100 | return err | 141 | return err | ||
| 101 | } | 142 | } | ||
| 102 | - | next, changed := removeTarget(state.pinned, target) | 143 | + | next, changed := removeRegistration(state.pinned, target) |
| 103 | if !changed { | 144 | if !changed { | ||
| 104 | return nil | 145 | return nil | ||
| 105 | } | 146 | } | ||
| @@ -117,14 +158,23 @@ func (s *fileHostStore) Remember(target string) error { | |||||
| 117 | return err | 158 | return err | ||
| 118 | } | 159 | } | ||
| 119 | err := s.withLock(func() error { | 160 | err := s.withLock(func() error { | ||
| 120 | - | state, err := s.loadLocked() | 161 | + | state, err := s.loadMigratedLocked() |
| 121 | if err != nil { | 162 | if err != nil { | ||
| 122 | return err | 163 | return err | ||
| 123 | } | 164 | } | ||
| 124 | if _, ok := state.learned[target]; ok { | 165 | if _, ok := state.learned[target]; ok { | ||
| 125 | return nil | 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 | return s.saveLocked(state) | 178 | return s.saveLocked(state) | ||
| 129 | }) | 179 | }) | ||
| 130 | if err != nil { | 180 | if err != nil { | ||
| @@ -138,7 +188,7 @@ func (s *fileHostStore) Forget(target string) error { | |||||
| 138 | return err | 188 | return err | ||
| 139 | } | 189 | } | ||
| 140 | err := s.withLock(func() error { | 190 | err := s.withLock(func() error { | ||
| 141 | - | state, err := s.loadLocked() | 191 | + | state, err := s.loadMigratedLocked() |
| 142 | if err != nil { | 192 | if err != nil { | ||
| 143 | return err | 193 | return err | ||
| 144 | } | 194 | } | ||
| @@ -159,15 +209,17 @@ func (s *fileHostStore) Remove(target string) (deleted bool, err error) { | |||||
| 159 | return false, err | 209 | return false, err | ||
| 160 | } | 210 | } | ||
| 161 | err = s.withLock(func() error { | 211 | err = s.withLock(func() error { | ||
| 162 | - | state, err := s.loadLocked() | 212 | + | state, err := s.loadMigratedLocked() |
| 163 | if err != nil { | 213 | if err != nil { | ||
| 164 | return err | 214 | return err | ||
| 165 | } | 215 | } | ||
| 166 | - | nextPinned, pinnedChanged := removeTarget(state.pinned, target) | 216 | + | nextPinned, pinnedChanged := removeRegistration(state.pinned, target) |
| 167 | _, learnedPresent := state.learned[target] | 217 | _, learnedPresent := state.learned[target] | ||
| 168 | if !pinnedChanged && !learnedPresent { | 218 | if !pinnedChanged && !learnedPresent { | ||
| 169 | return nil | 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 | state.pinned = nextPinned | 223 | state.pinned = nextPinned | ||
| 172 | delete(state.learned, target) | 224 | delete(state.learned, target) | ||
| 173 | if err := s.saveLocked(state); err != nil { | 225 | if err := s.saveLocked(state); err != nil { | ||
| @@ -202,12 +254,17 @@ func (s *fileHostStore) withLock(fn func() error) error { | |||||
| 202 | return fn() | 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 | raw, err := os.ReadFile(s.path) | 263 | raw, err := os.ReadFile(s.path) | ||
| 207 | if errors.Is(err, os.ErrNotExist) { | 264 | if errors.Is(err, os.ErrNotExist) { | ||
| 208 | return hostState{ | 265 | return hostState{ | ||
| 209 | - | pinned: make([]string, 0), | 266 | + | pinned: make([]domain.RemoteRegistration, 0), |
| 210 | - | learned: make(map[string]struct{}), | 267 | + | learned: make(map[string]domain.RemoteRegistration), |
| 211 | }, nil | 268 | }, nil | ||
| 212 | } | 269 | } | ||
| 213 | if err != nil { | 270 | if err != nil { | ||
| @@ -224,52 +281,152 @@ func (s *fileHostStore) loadLocked() (hostState, error) { | |||||
| 224 | } | 281 | } | ||
| 225 | 282 | ||||
| 226 | switch header.Version { | 283 | switch header.Version { | ||
| 227 | - | case 1: | 284 | + | case hostsFileVersion: |
| 228 | - | var legacy legacyHostsFile | 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 | if err := json.Unmarshal(raw, &legacy); err != nil { | 340 | if err := json.Unmarshal(raw, &legacy); err != nil { | ||
| 230 | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err) | 341 | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err) | ||
| 231 | } | 342 | } | ||
| 232 | if legacy.Hosts == nil { | 343 | if legacy.Hosts == nil { | ||
| 233 | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing hosts") | 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 | if err != nil { | 347 | if err != nil { | ||
| 237 | return hostState{}, err | 348 | return hostState{}, err | ||
| 238 | } | 349 | } | ||
| 239 | - | return hostState{pinned: []string{}, learned: learned}, nil | 350 | + | return hostState{pinned: []domain.RemoteRegistration{}, learned: learned}, nil |
| 240 | - | case hostsFileVersion: | 351 | + | } |
| 241 | - | var decoded hostsFile | 352 | + | var legacy legacyHostsFile |
| 242 | - | if err := json.Unmarshal(raw, &decoded); err != nil { | 353 | + | if err := json.Unmarshal(raw, &legacy); err != nil { |
| 243 | - | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err) | 354 | + | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: %w", err) |
| 244 | - | } | 355 | + | } |
| 245 | - | if decoded.Pinned == nil { | 356 | + | if legacy.Pinned == nil { |
| 246 | - | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing pinned") | 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 { | 387 | + | if _, dup := seen[host]; dup { |
| 249 | - | return hostState{}, fmt.Errorf("remote host store: malformed hosts file: missing learned") | 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 | if err != nil { | 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 | if err != nil { | 414 | if err != nil { | ||
| 257 | - | return hostState{}, err | 415 | + | return nil, err |
| 258 | } | 416 | } | ||
| 259 | - | return hostState{pinned: pinned, learned: learned}, nil | 417 | + | learned[host] = record |
| 260 | - | default: | |||
| 261 | - | return hostState{}, fmt.Errorf("remote host store: unsupported hosts file version %d", header.Version) | |||
| 262 | } | 418 | } | ||
| 419 | + | return learned, nil | |||
| 263 | } | 420 | } | ||
| 264 | 421 | ||||
| 265 | func (s *fileHostStore) saveLocked(state hostState) error { | 422 | func (s *fileHostStore) saveLocked(state hostState) error { | ||
| 266 | - | pinned := append([]string{}, state.pinned...) | 423 | + | pinned := make([]hostRecord, 0, len(state.pinned)) |
| 267 | - | if pinned == nil { | 424 | + | for _, record := range state.pinned { |
| 268 | - | pinned = []string{} | 425 | + | pinned = append(pinned, encodeRecord(record)) |
| 269 | } | 426 | } | ||
| 270 | - | learned := sortedHosts(state.learned) | 427 | + | learned := make([]hostRecord, 0, len(state.learned)) |
| 271 | - | if learned == nil { | 428 | + | for _, record := range sortedRegistrations(state.learned) { |
| 272 | - | learned = []string{} | 429 | + | learned = append(learned, encodeRecord(record)) |
| 273 | } | 430 | } | ||
| 274 | payload, err := json.Marshal(hostsFile{ | 431 | payload, err := json.Marshal(hostsFile{ | ||
| 275 | Version: hostsFileVersion, | 432 | Version: hostsFileVersion, | ||
| @@ -282,6 +439,31 @@ func (s *fileHostStore) saveLocked(state hostState) error { | |||||
| 282 | return atomicReplacePrivateFile(s.path, append(payload, '\n')) | 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 | func atomicReplacePrivateFile(path string, payload []byte) error { | 467 | func atomicReplacePrivateFile(path string, payload []byte) error { | ||
| 286 | dir := filepath.Dir(path) | 468 | dir := filepath.Dir(path) | ||
| 287 | if err := safedir.EnsurePrivate(dir); err != nil { | 469 | if err := safedir.EnsurePrivate(dir); err != nil { | ||
| @@ -318,32 +500,34 @@ func atomicReplacePrivateFile(path string, payload []byte) error { | |||||
| 318 | return syncDir(dir) | 500 | return syncDir(dir) | ||
| 319 | } | 501 | } | ||
| 320 | 502 | ||||
| 321 | - | func validatePinnedHosts(hosts []string) ([]string, error) { | 503 | + | func validatePinnedRecords(records []hostRecord) ([]domain.RemoteRegistration, error) { |
| 322 | - | pinned := make([]string, 0, len(hosts)) | 504 | + | pinned := make([]domain.RemoteRegistration, 0, len(records)) |
| 323 | - | seen := make(map[string]struct{}, len(hosts)) | 505 | + | seen := make(map[string]struct{}, len(records)) |
| 324 | - | for _, host := range hosts { | 506 | + | for _, record := range records { |
| 325 | - | if err := validateHostTarget(host); err != nil { | 507 | + | registration, err := decodeRecord(record) |
| 326 | - | return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err) | 508 | + | if err != nil { |
| 509 | + | return nil, err | |||
| 327 | } | 510 | } | ||
| 328 | - | if _, dup := seen[host]; dup { | 511 | + | if _, dup := seen[registration.Endpoint]; dup { |
| 329 | - | return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate pinned host %q", host) | 512 | + | return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate pinned host %q", registration.Endpoint) |
| 330 | } | 513 | } | ||
| 331 | - | seen[host] = struct{}{} | 514 | + | seen[registration.Endpoint] = struct{}{} |
| 332 | - | pinned = append(pinned, host) | 515 | + | pinned = append(pinned, registration) |
| 333 | } | 516 | } | ||
| 334 | return pinned, nil | 517 | return pinned, nil | ||
| 335 | } | 518 | } | ||
| 336 | 519 | ||||
| 337 | - | func validateLearnedHosts(hosts []string) (map[string]struct{}, error) { | 520 | + | func validateLearnedRecords(records []hostRecord) (map[string]domain.RemoteRegistration, error) { |
| 338 | - | learned := make(map[string]struct{}, len(hosts)) | 521 | + | learned := make(map[string]domain.RemoteRegistration, len(records)) |
| 339 | - | for _, host := range hosts { | 522 | + | for _, record := range records { |
| 340 | - | if err := validateHostTarget(host); err != nil { | 523 | + | registration, err := decodeRecord(record) |
| 341 | - | return nil, fmt.Errorf("remote host store: malformed hosts file: %w", err) | 524 | + | if err != nil { |
| 525 | + | return nil, err | |||
| 342 | } | 526 | } | ||
| 343 | - | if _, dup := learned[host]; dup { | 527 | + | if _, dup := learned[registration.Endpoint]; dup { |
| 344 | - | return nil, fmt.Errorf("remote host store: malformed hosts file: duplicate learned host %q", host) | 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 | return learned, nil | 532 | return learned, nil | ||
| 349 | } | 533 | } | ||
| @@ -355,25 +539,34 @@ func validateHostTarget(target string) error { | |||||
| 355 | return domain.ValidateRemoteHostTarget(target) | 539 | return domain.ValidateRemoteHostTarget(target) | ||
| 356 | } | 540 | } | ||
| 357 | 541 | ||||
| 358 | - | func removeTarget(hosts []string, target string) ([]string, bool) { | 542 | + | func findRegistration(registrations []domain.RemoteRegistration, target string) (domain.RemoteRegistration, bool) { |
| 359 | - | out := make([]string, 0, len(hosts)) | 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 | changed := false | 553 | changed := false | ||
| 361 | - | for _, host := range hosts { | 554 | + | for _, record := range registrations { |
| 362 | - | if host == target { | 555 | + | if record.Endpoint == target { |
| 363 | changed = true | 556 | changed = true | ||
| 364 | continue | 557 | continue | ||
| 365 | } | 558 | } | ||
| 366 | - | out = append(out, host) | 559 | + | out = append(out, record) |
| 367 | } | 560 | } | ||
| 368 | return out, changed | 561 | return out, changed | ||
| 369 | } | 562 | } | ||
| 370 | 563 | ||||
| 371 | - | func sortedHosts(hosts map[string]struct{}) []string { | 564 | + | func sortedRegistrations(registrations map[string]domain.RemoteRegistration) []domain.RemoteRegistration { |
| 372 | - | out := make([]string, 0, len(hosts)) | 565 | + | out := make([]domain.RemoteRegistration, 0, len(registrations)) |
| 373 | - | for host := range hosts { | 566 | + | for _, record := range registrations { |
| 374 | - | out = append(out, host) | 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 | return out | 570 | return out | ||
| 378 | } | 571 | } | ||
| 379 | 572 | ||||
M internal/adapters/remote/host_registry_test.go +269 -37
| @@ -11,6 +11,8 @@ import ( | |||||
| 11 | "strings" | 11 | "strings" | ||
| 12 | "sync" | 12 | "sync" | ||
| 13 | "testing" | 13 | "testing" | ||
| 14 | + | ||||
| 15 | + | "github.com/bnema/vev/internal/domain" | |||
| 14 | ) | 16 | ) | ||
| 15 | 17 | ||||
| 16 | func TestHostStorePath(t *testing.T) { | 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 | func TestHostStoreCrossProcessHelper(t *testing.T) { | 43 | func TestHostStoreCrossProcessHelper(t *testing.T) { | ||
| 25 | if os.Getenv("VEV_HOST_STORE_HELPER") != "1" { | 44 | if os.Getenv("VEV_HOST_STORE_HELPER") != "1" { | ||
| 26 | t.Skip("helper process") | 45 | t.Skip("helper process") | ||
| @@ -84,11 +103,28 @@ func TestHostStoreCrossProcessConcurrentRemember(t *testing.T) { | |||||
| 84 | } | 103 | } | ||
| 85 | want := append(append([]string{}, left...), right...) | 104 | want := append(append([]string{}, left...), right...) | ||
| 86 | sort.Strings(want) | 105 | sort.Strings(want) | ||
| 87 | - | if !slices.Equal(learned, want) { | 106 | + | if !slices.Equal(registrationEndpoints(learned), want) { |
| 88 | t.Fatalf("learned len=%d want %d; missing merges under cross-process writers", len(learned), len(want)) | 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 | func TestHostStore(t *testing.T) { | 128 | func TestHostStore(t *testing.T) { | ||
| 93 | t.Parallel() | 129 | t.Parallel() | ||
| 94 | 130 | ||||
| @@ -146,12 +182,14 @@ func TestHostStore(t *testing.T) { | |||||
| 146 | } | 182 | } | ||
| 147 | wantPinned := []string{"zebra", "arch"} | 183 | wantPinned := []string{"zebra", "arch"} | ||
| 148 | wantLearned := []string{"beta", "mule"} | 184 | wantLearned := []string{"beta", "mule"} | ||
| 149 | - | if !slices.Equal(pinned, wantPinned) { | 185 | + | if !slices.Equal(registrationEndpoints(pinned), wantPinned) { |
| 150 | t.Fatalf("pinned = %v, want %v", pinned, wantPinned) | 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 | t.Fatalf("learned = %v, want %v", learned, wantLearned) | 189 | t.Fatalf("learned = %v, want %v", learned, wantLearned) | ||
| 154 | } | 190 | } | ||
| 191 | + | requireValidRegistrations(t, pinned) | |||
| 192 | + | requireValidRegistrations(t, learned) | |||
| 155 | 193 | ||||
| 156 | info, err := os.Stat(path) | 194 | info, err := os.Stat(path) | ||
| 157 | if err != nil { | 195 | if err != nil { | ||
| @@ -176,15 +214,31 @@ func TestHostStore(t *testing.T) { | |||||
| 176 | if err := json.Unmarshal(raw, &decoded); err != nil { | 214 | if err := json.Unmarshal(raw, &decoded); err != nil { | ||
| 177 | t.Fatalf("decode store: %v", err) | 215 | t.Fatalf("decode store: %v", err) | ||
| 178 | } | 216 | } | ||
| 179 | - | if decoded.Version != 2 { | 217 | + | if decoded.Version != 3 { |
| 180 | - | t.Fatalf("version = %d, want 2", decoded.Version) | 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 | t.Fatalf("stored pinned = %v, want %v", decoded.Pinned, wantPinned) | 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 | t.Fatalf("stored learned = %v, want %v", decoded.Learned, wantLearned) | 239 | t.Fatalf("stored learned = %v, want %v", decoded.Learned, wantLearned) | ||
| 187 | } | 240 | } | ||
| 241 | + | requireValidRegistrations(t, stored) | |||
| 188 | }) | 242 | }) | ||
| 189 | 243 | ||||
| 190 | t.Run("overlap allowed across pinned and learned", func(t *testing.T) { | 244 | t.Run("overlap allowed across pinned and learned", func(t *testing.T) { | ||
| @@ -202,12 +256,17 @@ func TestHostStore(t *testing.T) { | |||||
| 202 | if err != nil { | 256 | if err != nil { | ||
| 203 | t.Fatalf("Hosts() error = %v", err) | 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 | t.Fatalf("pinned = %v, want [arch]", pinned) | 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 | t.Fatalf("learned = %v, want [arch]", learned) | 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 | t.Run("idempotent mutations and remove from both sources", func(t *testing.T) { | 272 | t.Run("idempotent mutations and remove from both sources", func(t *testing.T) { | ||
| @@ -255,7 +314,7 @@ func TestHostStore(t *testing.T) { | |||||
| 255 | if len(pinned) != 0 { | 314 | if len(pinned) != 0 { | ||
| 256 | t.Fatalf("pinned after Remove(arch) = %v, want empty", pinned) | 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 | t.Fatalf("learned after Remove(arch) = %v, want [mule]", learned) | 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 | t.Parallel() | 366 | t.Parallel() | ||
| 308 | cases := []struct { | 367 | cases := []struct { | ||
| 309 | name string | 368 | name string | ||
| @@ -324,21 +383,13 @@ func TestHostStore(t *testing.T) { | |||||
| 324 | if err != nil { | 383 | if err != nil { | ||
| 325 | t.Fatalf("Hosts() error = %v", err) | 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 | t.Fatalf("Hosts() = pinned %v learned %v, want pinned empty learned %v", pinned, learned, tc.wantLearned) | 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 | raw, err := os.ReadFile(path) | 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 | if err != nil { | 393 | if err != nil { | ||
| 343 | t.Fatalf("read migrated store: %v", err) | 394 | t.Fatalf("read migrated store: %v", err) | ||
| 344 | } | 395 | } | ||
| @@ -349,13 +400,163 @@ func TestHostStore(t *testing.T) { | |||||
| 349 | if decoded.Version != hostsFileVersion { | 400 | if decoded.Version != hostsFileVersion { | ||
| 350 | t.Fatalf("migrated version = %d, want %d", decoded.Version, hostsFileVersion) | 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) { | 403 | + | backup, err := os.ReadFile(path + hostsBackupSuffix) |
| 353 | - | t.Fatalf("migrated store = pinned %v learned %v", decoded.Pinned, decoded.Learned) | 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 | t.Run("schema failures fail closed and preserve file", func(t *testing.T) { | 560 | t.Run("schema failures fail closed and preserve file", func(t *testing.T) { | ||
| 360 | t.Parallel() | 561 | t.Parallel() | ||
| 361 | cases := []struct { | 562 | cases := []struct { | ||
| @@ -363,7 +564,7 @@ func TestHostStore(t *testing.T) { | |||||
| 363 | raw string | 564 | raw string | ||
| 364 | wantError string | 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 | {name: "null pinned", raw: `{"version":2,"pinned":null,"learned":[]}`, wantError: "malformed hosts file"}, | 568 | {name: "null pinned", raw: `{"version":2,"pinned":null,"learned":[]}`, wantError: "malformed hosts file"}, | ||
| 368 | {name: "null learned", raw: `{"version":2,"pinned":[],"learned":null}`, wantError: "malformed hosts file"}, | 569 | {name: "null learned", raw: `{"version":2,"pinned":[],"learned":null}`, wantError: "malformed hosts file"}, | ||
| 369 | {name: "duplicate pinned", raw: `{"version":2,"pinned":["arch","arch"],"learned":[]}`, wantError: "malformed hosts file"}, | 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 | {name: "legacy duplicate hosts", raw: `{"version":1,"hosts":["arch","arch"]}`, wantError: "malformed hosts file: duplicate learned host"}, | 576 | {name: "legacy duplicate hosts", raw: `{"version":1,"hosts":["arch","arch"]}`, wantError: "malformed hosts file: duplicate learned host"}, | ||
| 376 | {name: "legacy invalid target", raw: `{"version":1,"hosts":[" arch"]}`, wantError: "malformed hosts file"}, | 577 | {name: "legacy invalid target", raw: `{"version":1,"hosts":[" arch"]}`, wantError: "malformed hosts file"}, | ||
| 377 | {name: "legacy invalid utf8", raw: "{\"version\":1,\"hosts\":[\"\xff\"]}", wantError: "malformed hosts file"}, | 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 | for _, tc := range cases { | 587 | for _, tc := range cases { | ||
| 380 | t.Run(tc.name, func(t *testing.T) { | 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 | t.Parallel() | 614 | t.Parallel() | ||
| 407 | path := storePath(t) | 615 | path := storePath(t) | ||
| 408 | - | unsorted := []byte(`{"version":2,"pinned":["zebra","arch"],"learned":["mule","beta"]}` + "\n") | 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" |
| 409 | - | writeHosts(t, path, unsorted) | 617 | + | writeHosts(t, path, []byte(seeded)) |
| 410 | 618 | ||||
| 411 | store := NewFileHostStore(path) | 619 | store := NewFileHostStore(path) | ||
| 412 | pinned, learned, err := store.Hosts() | 620 | pinned, learned, err := store.Hosts() | ||
| 413 | if err != nil { | 621 | if err != nil { | ||
| 414 | t.Fatalf("Hosts() error = %v", err) | 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 | t.Fatalf("pinned = %v, want [zebra arch]", pinned) | 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 | t.Fatalf("learned = %v, want [beta mule]", learned) | 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 | raw, err := os.ReadFile(path) | 632 | raw, err := os.ReadFile(path) | ||
| 423 | if err != nil { | 633 | if err != nil { | ||
| 424 | t.Fatalf("read store: %v", err) | 634 | t.Fatalf("read store: %v", err) | ||
| 425 | } | 635 | } | ||
| 426 | - | if string(raw) != string(unsorted) { | 636 | + | if string(raw) != seeded { |
| 427 | t.Fatalf("read-only Hosts rewrote file: got %q", raw) | 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 | if err := json.Unmarshal(raw, &decoded); err != nil { | 648 | if err := json.Unmarshal(raw, &decoded); err != nil { | ||
| 439 | t.Fatalf("decode: %v", err) | 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 | t.Fatalf("pinned after mutation = %v", decoded.Pinned) | 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 | t.Fatalf("learned after mutation = %v", decoded.Learned) | 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 | t.Run("rejects invalid targets", func(t *testing.T) { | 681 | t.Run("rejects invalid targets", func(t *testing.T) { | ||
| @@ -650,15 +882,15 @@ func TestHostStore(t *testing.T) { | |||||
| 650 | } | 882 | } | ||
| 651 | wantLearned := append([]string{}, left...) | 883 | wantLearned := append([]string{}, left...) | ||
| 652 | sort.Strings(wantLearned) | 884 | sort.Strings(wantLearned) | ||
| 653 | - | if !slices.Equal(learned, wantLearned) { | 885 | + | if !slices.Equal(registrationEndpoints(learned), wantLearned) { |
| 654 | t.Fatalf("learned len=%d want %d; missing merges under dual writers", len(learned), len(wantLearned)) | 886 | t.Fatalf("learned len=%d want %d; missing merges under dual writers", len(learned), len(wantLearned)) | ||
| 655 | } | 887 | } | ||
| 656 | if len(pinned) != len(right) { | 888 | if len(pinned) != len(right) { | ||
| 657 | t.Fatalf("pinned len=%d want %d", len(pinned), len(right)) | 889 | t.Fatalf("pinned len=%d want %d", len(pinned), len(right)) | ||
| 658 | } | 890 | } | ||
| 659 | pinnedSet := make(map[string]struct{}, len(pinned)) | 891 | pinnedSet := make(map[string]struct{}, len(pinned)) | ||
| 660 | - | for _, host := range pinned { | 892 | + | for _, record := range pinned { |
| 661 | - | pinnedSet[host] = struct{}{} | 893 | + | pinnedSet[record.Endpoint] = struct{}{} |
| 662 | } | 894 | } | ||
| 663 | for _, host := range right { | 895 | for _, host := range right { | ||
| 664 | if _, ok := pinnedSet[host]; !ok { | 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 | return CommandSpec{Path: "ssh", Args: args} | 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 | // BuildCommandForRemoteLaunch prefixes an explicit complete environment and | 557 | // BuildCommandForRemoteLaunch prefixes an explicit complete environment and | ||
| 530 | // absolute executable to a hidden remote mode. Each value remains one quoted | 558 | // absolute executable to a hidden remote mode. Each value remains one quoted | ||
| 531 | // remote-shell word; no caller input is concatenated into an unquoted command. | 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 | "errors" | 7 | "errors" | ||
| 8 | "io" | 8 | "io" | ||
| 9 | "log/slog" | 9 | "log/slog" | ||
| 10 | + | "os" | |||
| 10 | "os/exec" | 11 | "os/exec" | ||
| 11 | "strings" | 12 | "strings" | ||
| 12 | "sync" | 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 | func TestCloseInterruptsBlockedSend(t *testing.T) { | 189 | func TestCloseInterruptsBlockedSend(t *testing.T) { | ||
| 113 | writer := &blockedWriter{started: make(chan struct{}), released: make(chan struct{})} | 190 | writer := &blockedWriter{started: make(chan struct{}), released: make(chan struct{})} | ||
| 114 | transport := NewTransport(nil, writer, nil) | 191 | transport := NewTransport(nil, writer, nil) | ||
M internal/app/launch_allowlist.go +12 -2
| @@ -40,9 +40,9 @@ type allowlistedRemoteHostStore struct { | |||||
| 40 | allowed map[string]struct{} | 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 | pinned, learned, err = s.delegate.Hosts() | 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 | func (s allowlistedRemoteHostStore) AddPinned(target string) error { | 48 | func (s allowlistedRemoteHostStore) AddPinned(target string) error { | ||
| @@ -143,6 +143,16 @@ func filterRemoteTargets(targets []string, allowed map[string]struct{}) []string | |||||
| 143 | return filtered | 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 | func endpointNotConfiguredError() error { | 156 | func endpointNotConfiguredError() error { | ||
| 147 | return &ports.UIError{Code: ports.UIErrEndpointNotConfigured} | 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 | // mergeRemoteHosts returns pinned hosts in stored order, then learned-only | 233 | // mergeRemoteHosts returns pinned hosts in stored order, then learned-only | ||
| 234 | // hosts in lexical order. Duplicates keep first occurrence and mark both sources. | 234 | // hosts in lexical order. Duplicates keep first occurrence and mark both sources. | ||
| 235 | - | func mergeRemoteHosts(pinned, learned []string) []domain.RemoteHost { | 235 | + | func mergeRemoteHosts(pinned, learned []domain.RemoteRegistration) []domain.RemoteHost { |
| 236 | - | pinned = domain.UniqueRemoteHostTargets(pinned) | 236 | + | pinned = domain.UniqueRemoteRegistrations(pinned) |
| 237 | learnedSet := make(map[string]struct{}, len(learned)) | 237 | learnedSet := make(map[string]struct{}, len(learned)) | ||
| 238 | - | for _, target := range learned { | 238 | + | for _, record := range learned { |
| 239 | - | learnedSet[target] = struct{}{} | 239 | + | learnedSet[record.Endpoint] = struct{}{} |
| 240 | } | 240 | } | ||
| 241 | pinnedSet := make(map[string]struct{}, len(pinned)) | 241 | pinnedSet := make(map[string]struct{}, len(pinned)) | ||
| 242 | out := make([]domain.RemoteHost, 0, len(pinned)+len(learned)) | 242 | out := make([]domain.RemoteHost, 0, len(pinned)+len(learned)) | ||
| 243 | - | for _, target := range pinned { | 243 | + | for _, record := range pinned { |
| 244 | - | pinnedSet[target] = struct{}{} | 244 | + | pinnedSet[record.Endpoint] = struct{}{} |
| 245 | - | _, isLearned := learnedSet[target] | 245 | + | _, isLearned := learnedSet[record.Endpoint] |
| 246 | - | out = append(out, domain.RemoteHost{Target: target, Pinned: true, Learned: isLearned}) | 246 | + | out = append(out, domain.RemoteHost{Target: record.Endpoint, Pinned: true, Learned: isLearned, Registration: record}) |
| 247 | - | } | 247 | + | } |
| 248 | - | learnedOnly := make([]string, 0, len(learned)) | 248 | + | learnedOnly := make([]domain.RemoteRegistration, 0, len(learned)) |
| 249 | - | for _, target := range learned { | 249 | + | for _, record := range learned { |
| 250 | - | if _, ok := pinnedSet[target]; !ok { | 250 | + | if _, ok := pinnedSet[record.Endpoint]; !ok { |
| 251 | - | learnedOnly = append(learnedOnly, target) | 251 | + | learnedOnly = append(learnedOnly, record) |
| 252 | } | 252 | } | ||
| 253 | } | 253 | } | ||
| 254 | - | learnedOnly = domain.UniqueRemoteHostTargets(learnedOnly) | 254 | + | learnedOnly = domain.UniqueRemoteRegistrations(learnedOnly) |
| 255 | - | sort.Strings(learnedOnly) | 255 | + | sort.Slice(learnedOnly, func(i, j int) bool { return learnedOnly[i].Endpoint < learnedOnly[j].Endpoint }) |
| 256 | - | for _, target := range learnedOnly { | 256 | + | for _, record := range learnedOnly { |
| 257 | - | out = append(out, domain.RemoteHost{Target: target, Learned: true}) | 257 | + | out = append(out, domain.RemoteHost{Target: record.Endpoint, Learned: true, Registration: record}) |
| 258 | } | 258 | } | ||
| 259 | return out | 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 | func TestMergeRemoteHostsOrderAndSource(t *testing.T) { | 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 | require.Equal(t, []domain.RemoteHost{ | 96 | require.Equal(t, []domain.RemoteHost{ | ||
| 78 | - | {Target: "zebra", Pinned: true}, | 97 | + | {Target: "zebra", Pinned: true, Registration: zebra}, |
| 79 | - | {Target: "arch", Pinned: true, Learned: true}, | 98 | + | {Target: "arch", Pinned: true, Learned: true, Registration: archPinned}, |
| 80 | - | {Target: "beta", Learned: true}, | 99 | + | {Target: "beta", Learned: true, Registration: beta}, |
| 81 | - | {Target: "mule", Learned: true}, | 100 | + | {Target: "mule", Learned: true, Registration: mule}, |
| 82 | }, got) | 101 | }, got) | ||
| 83 | } | 102 | } | ||
| 84 | 103 | ||||
| @@ -135,7 +154,7 @@ func TestRemoteHostCommands(t *testing.T) { | |||||
| 135 | name: "list preserves source markers and order", | 154 | name: "list preserves source markers and order", | ||
| 136 | run: func(t *testing.T) { | 155 | run: func(t *testing.T) { | ||
| 137 | store := portsmocks.NewMockRemoteHostStore(t) | 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 | var out bytes.Buffer | 158 | var out bytes.Buffer | ||
| 140 | err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, &out)) | 159 | err := runHostCommand(context.Background(), command{hostAction: hostActionList}, newDeps(store, &out)) | ||
| 141 | require.NoError(t, err) | 160 | require.NoError(t, err) | ||
| @@ -173,7 +192,7 @@ func TestRemoteHostCommands(t *testing.T) { | |||||
| 173 | 192 | ||||
| 174 | func TestRemoteHostListingUsesUnifiedStore(t *testing.T) { | 193 | func TestRemoteHostListingUsesUnifiedStore(t *testing.T) { | ||
| 175 | store := portsmocks.NewMockRemoteHostStore(t) | 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 | catalog := portsmocks.NewMockRemoteCatalogClient(t) | 196 | catalog := portsmocks.NewMockRemoteCatalogClient(t) | ||
| 178 | catalog.EXPECT().List(mock.Anything, "arch").Return(catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "build", State: "up"}}}, nil).Once() | 197 | catalog.EXPECT().List(mock.Anything, "arch").Return(catalogue.RemoteCatalog{Sessions: []catalogue.RemoteCatalogSession{{Name: "build", State: "up"}}}, nil).Once() | ||
| 179 | var out bytes.Buffer | 198 | var out bytes.Buffer | ||
M internal/app/run.go +10 -6
| @@ -51,6 +51,7 @@ import ( | |||||
| 51 | "github.com/bnema/vev/internal/usecase/client" | 51 | "github.com/bnema/vev/internal/usecase/client" | ||
| 52 | "github.com/bnema/vev/internal/usecase/daemon" | 52 | "github.com/bnema/vev/internal/usecase/daemon" | ||
| 53 | "github.com/bnema/vev/internal/usecase/recovery" | 53 | "github.com/bnema/vev/internal/usecase/recovery" | ||
| 54 | + | "github.com/bnema/vev/internal/usecase/remotes" | |||
| 54 | pdgram "github.com/bnema/vev/pkg/dgram" | 55 | pdgram "github.com/bnema/vev/pkg/dgram" | ||
| 55 | "github.com/bnema/vev/pkg/safedir" | 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 | var remoteDiscoveryOpt daemon.Option | 708 | var remoteDiscoveryOpt daemon.Option | ||
| 708 | if allowlistConfigured { | 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 | } else { | 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 | if err != nil { | 714 | if err != nil { | ||
| 714 | return err | 715 | return err | ||
| @@ -925,8 +926,10 @@ func validateRemoteAttachHandoff(target protocol.AttachTarget) error { | |||||
| 925 | } | 926 | } | ||
| 926 | 927 | ||||
| 927 | // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from | 928 | // remoteDiscoveryDaemonOption constructs the daemon-owned discovery ports from | ||
| 928 | - | // the same validated transport selection used by direct remote attach. | 929 | + | // the same validated transport selection used by direct remote attach, then |
| 929 | - | func remoteDiscoveryDaemonOption(stateDir, transport string, allowlists ...map[string]struct{}) (daemon.Option, error) { | 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 | _, err := remoteTransportModeFromEnv(transport) | 933 | _, err := remoteTransportModeFromEnv(transport) | ||
| 931 | if err != nil { | 934 | if err != nil { | ||
| 932 | return nil, err | 935 | return nil, err | ||
| @@ -942,11 +945,12 @@ func remoteDiscoveryDaemonOption(stateDir, transport string, allowlists ...map[s | |||||
| 942 | cache = allowlistedRemoteCatalogCache{delegate: cache, allowed: allowed} | 945 | cache = allowlistedRemoteCatalogCache{delegate: cache, allowed: allowed} | ||
| 943 | previewClient = allowlistedRemotePreviewClient{delegate: previewClient, allowed: allowed} | 946 | previewClient = allowlistedRemotePreviewClient{delegate: previewClient, allowed: allowed} | ||
| 944 | } | 947 | } | ||
| 945 | - | discovery := daemon.WithRemoteDiscovery(store, catalog, cache) | |||
| 946 | preview := daemon.WithRemotePreview(previewClient) | 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 | return func(d *daemon.Daemon) { | 951 | return func(d *daemon.Daemon) { | ||
| 948 | - | discovery(d) | |||
| 949 | preview(d) | 952 | preview(d) | ||
| 953 | + | remoteMonitor(d) | |||
| 950 | }, nil | 954 | }, nil | ||
| 951 | } | 955 | } | ||
| 952 | 956 | ||||
M internal/app/run_test.go +4 -3
| @@ -17,6 +17,7 @@ import ( | |||||
| 17 | "testing" | 17 | "testing" | ||
| 18 | "time" | 18 | "time" | ||
| 19 | 19 | ||||
| 20 | + | "github.com/bnema/vev/internal/adapters/clock" | |||
| 20 | "github.com/bnema/vev/internal/adapters/lifecycle" | 21 | "github.com/bnema/vev/internal/adapters/lifecycle" | ||
| 21 | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | 22 | remoteadapter "github.com/bnema/vev/internal/adapters/remote" | ||
| 22 | "github.com/bnema/vev/internal/adapters/sessionwire" | 23 | "github.com/bnema/vev/internal/adapters/sessionwire" | ||
| @@ -1418,15 +1419,15 @@ func TestRemoteDiscoveryDaemonOptionWiresProductionPorts(t *testing.T) { | |||||
| 1418 | newRemoteCatalogClient = func() ports.RemoteCatalogClient { return nil } | 1419 | newRemoteCatalogClient = func() ports.RemoteCatalogClient { return nil } | ||
| 1419 | newRemoteDialerFactoryWithRuntimeObserver = func(ports.SerializedRuntimeObserver) remoteDialerForTarget { return nil } | 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 | require.NoError(t, err) | 1423 | require.NoError(t, err) | ||
| 1423 | _ = daemon.New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), option) | 1424 | _ = daemon.New(nil, nil, slog.New(slog.NewTextHandler(io.Discard, nil)), option) | ||
| 1424 | require.Equal(t, remoteadapter.HostStorePath(stateDir), hostPath) | 1425 | require.Equal(t, remoteadapter.HostStorePath(stateDir), hostPath) | ||
| 1425 | require.Equal(t, remoteadapter.CatalogCachePath(stateDir), cachePath) | 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 | func TestRemoteDiscoveryDaemonOptionRejectsInvalidTransport(t *testing.T) { | 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 | require.EqualError(t, err, `vev: invalid remote transport "serial" (want "udp" or "stdio")`) | 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 | // RemoteHost is a merged remote host entry with its source markers. | 9 | // RemoteHost is a merged remote host entry with its source markers. | ||
| 10 | type RemoteHost struct { | 10 | type RemoteHost struct { | ||
| 11 | - | Target string | 11 | + | Target string |
| 12 | - | Pinned bool | 12 | + | Pinned bool |
| 13 | - | Learned bool | 13 | + | Learned bool |
| 14 | + | Registration RemoteRegistration | |||
| 14 | } | 15 | } | ||
| 15 | 16 | ||||
| 16 | // ValidateRemoteHostTarget rejects empty targets, surrounding/internal whitespace, | 17 | // ValidateRemoteHostTarget rejects empty targets, surrounding/internal whitespace, | ||
| @@ -43,3 +44,18 @@ func UniqueRemoteHostTargets(values []string) []string { | |||||
| 43 | } | 44 | } | ||
| 44 | return out | 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 | // Hosts provides a mock function for the type MockRemoteHostStore | 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 | ret := _mock.Called() | 147 | ret := _mock.Called() | ||
| 148 | 148 | ||||
| 149 | if len(ret) == 0 { | 149 | if len(ret) == 0 { | ||
| 150 | panic("no return value specified for Hosts") | 150 | panic("no return value specified for Hosts") | ||
| 151 | } | 151 | } | ||
| 152 | 152 | ||||
| 153 | - | var r0 []string | 153 | + | var r0 []domain.RemoteRegistration |
| 154 | - | var r1 []string | 154 | + | var r1 []domain.RemoteRegistration |
| 155 | var r2 error | 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 | return returnFunc() | 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 | r0 = returnFunc() | 160 | r0 = returnFunc() | ||
| 161 | } else { | 161 | } else { | ||
| 162 | if ret.Get(0) != nil { | 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 | r1 = returnFunc() | 167 | r1 = returnFunc() | ||
| 168 | } else { | 168 | } else { | ||
| 169 | if ret.Get(1) != nil { | 169 | if ret.Get(1) != nil { | ||
| 170 | - | r1 = ret.Get(1).([]string) | 170 | + | r1 = ret.Get(1).([]domain.RemoteRegistration) |
| 171 | } | 171 | } | ||
| 172 | } | 172 | } | ||
| 173 | if returnFunc, ok := ret.Get(2).(func() error); ok { | 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 | return _c | 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 | _c.Call.Return(pinned, learned, err) | 199 | _c.Call.Return(pinned, learned, err) | ||
| 200 | return _c | 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 | _c.Call.Return(run) | 204 | _c.Call.Return(run) | ||
| 205 | return _c | 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 | // ObserveTerminalWrite provides a mock function for the type MockUIObservationSink | 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 | func (_mock *MockUIObservationSink) ObserveTerminalWrite(data []byte) { | 373 | func (_mock *MockUIObservationSink) ObserveTerminalWrite(data []byte) { | ||
| 369 | _mock.Called(append([]byte(nil), data...)) | 374 | _mock.Called(append([]byte(nil), data...)) | ||
| 370 | return | 375 | return | ||
M internal/ports/remote.go +5 -2
| @@ -8,9 +8,12 @@ import ( | |||||
| 8 | "github.com/bnema/vev/internal/protocol/catalogue" | 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 | type RemoteHostStore interface { | 15 | type RemoteHostStore interface { | ||
| 13 | - | Hosts() (pinned, learned []string, err error) | 16 | + | Hosts() (pinned, learned []domain.RemoteRegistration, err error) |
| 14 | AddPinned(target string) error | 17 | AddPinned(target string) error | ||
| 15 | RemovePinned(target string) error | 18 | RemovePinned(target string) error | ||
| 16 | Remember(target string) error | 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 | // RemoteCatalogCacheEntry is one immutable exact-schema host snapshot. | 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 | type RemoteCatalogCacheEntry struct { | 34 | type RemoteCatalogCacheEntry struct { | ||
| 33 | - | Host string | 35 | + | Host string |
| 34 | - | FetchedAt time.Time | 36 | + | FetchedAt time.Time |
| 35 | - | Sessions []RemoteCatalogSession | 37 | + | Incarnation [16]byte |
| 38 | + | Sessions []RemoteCatalogSession | |||
| 36 | } | 39 | } | ||
| 37 | 40 | ||||
| 38 | // RemoteCatalogTab is one bounded, untrusted tab presentation hint. | 41 | // RemoteCatalogTab is one bounded, untrusted tab presentation hint. | ||
| @@ -264,7 +267,7 @@ func validText(value string) bool { | |||||
| 264 | 267 | ||||
| 265 | func validReason(reason string) bool { | 268 | func validReason(reason string) bool { | ||
| 266 | switch reason { | 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 | return true | 271 | return true | ||
| 269 | default: | 272 | default: | ||
| 270 | return false | 273 | return false | ||
M internal/usecase/daemon/control.go +1 -1
| @@ -748,7 +748,7 @@ func (e controlExec) RemoteCatalog(asJSON bool) (string, error) { | |||||
| 748 | reason := "" | 748 | reason := "" | ||
| 749 | if entry.broken() { | 749 | if entry.broken() { | ||
| 750 | state = catalogue.RemoteCatalogSessionBroken | 750 | state = catalogue.RemoteCatalogSessionBroken | ||
| 751 | - | reason = "session_broken" | 751 | + | reason = domain.RemoteReasonSessionBroken |
| 752 | } | 752 | } | ||
| 753 | rows = append(rows, catalogue.RemoteCatalogSession{ | 753 | rows = append(rows, catalogue.RemoteCatalogSession{ | ||
| 754 | LifecycleID: entry.incarnation, | 754 | LifecycleID: entry.incarnation, | ||
M internal/usecase/daemon/control_test.go +24 -0
| @@ -737,6 +737,30 @@ func TestHandleCommandListingsContainStableIDsMarkersAndCWD(t *testing.T) { | |||||
| 737 | require.Equal(t, true, decoded[0]["focused"]) | 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 | func TestRemoteCatalogJSONOutput(t *testing.T) { | 764 | func TestRemoteCatalogJSONOutput(t *testing.T) { | ||
| 741 | d := newTestDaemon(t, nil, stubClock{}) | 765 | d := newTestDaemon(t, nil, stubClock{}) | ||
| 742 | work := addControlSession(d, "work", "t_work", "p_work") | 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 | barScripts *barScriptState | 298 | barScripts *barScriptState | ||
| 299 | notices *noticeCenter | 299 | notices *noticeCenter | ||
| 300 | resumeParkGrace time.Duration | 300 | resumeParkGrace time.Duration | ||
| 301 | - | // remoteCatalog owns cache-derived discovery state independently of the | 301 | + | remotePreview remotePreviewState |
| 302 | - | // live attachment registry. Its cache reads and writes never hold d.mu. | 302 | + | remotePreviewClient ports.RemotePreviewClient |
| 303 | - | remoteCatalog remoteCatalogState | 303 | + | // remoteDirectory is the snapshot-only projection served by the remote |
| 304 | - | remotePreview remotePreviewState | 304 | + | // monitor. Presentation reads it without I/O; nil disables remote |
| 305 | - | remoteHostStore ports.RemoteHostStore | 305 | + | // directory projections without affecting local behavior. |
| 306 | - | remoteCatalogClient ports.RemoteCatalogClient | 306 | + | remoteDirectory ports.RemoteDirectory |
| 307 | - | remoteCatalogCache ports.RemoteCatalogCache | 307 | + | // remoteMonitorRun starts the monitor policy loop. Serve owns its |
| 308 | - | remotePreviewClient ports.RemotePreviewClient | 308 | + | // lifetime; nil disables background monitoring. |
| 309 | + | remoteMonitorRun func(context.Context) error | |||
| 309 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | 310 | // tempDir overrides os.TempDir() for clipboard-image-transfer writes | ||
| 310 | // (see clipboard.go); empty means use os.TempDir(). | 311 | // (see clipboard.go); empty means use os.TempDir(). | ||
| 311 | tempDir string | 312 | tempDir string | ||
| @@ -415,12 +416,13 @@ func WithRuntimeObserver(observer ports.SerializedRuntimeObserver) Option { | |||||
| 415 | return func(d *Daemon) { d.runtimeObserver = observer } | 416 | return func(d *Daemon) { d.runtimeObserver = observer } | ||
| 416 | } | 417 | } | ||
| 417 | 418 | ||||
| 418 | - | // WithRemoteDiscovery installs the remote discovery ports used by the daemon. | 419 | + | // WithRemoteMonitor installs the snapshot-only remote directory and the |
| 419 | - | func WithRemoteDiscovery(store ports.RemoteHostStore, catalog ports.RemoteCatalogClient, cache ports.RemoteCatalogCache) Option { | 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 | return func(d *Daemon) { | 423 | return func(d *Daemon) { | ||
| 421 | - | d.remoteHostStore = store | 424 | + | d.remoteDirectory = directory |
| 422 | - | d.remoteCatalogClient = catalog | 425 | + | d.remoteMonitorRun = run |
| 423 | - | d.remoteCatalogCache = cache | |||
| 424 | } | 426 | } | ||
| 425 | } | 427 | } | ||
| 426 | 428 | ||||
| @@ -606,7 +608,18 @@ func (d *Daemon) allocateLifecycleCreatedAtLocked() (int64, error) { | |||||
| 606 | } | 608 | } | ||
| 607 | 609 | ||||
| 608 | func (d *Daemon) nowUnixNano() int64 { | 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 | type systemClock struct{} | 625 | type systemClock struct{} | ||
| @@ -670,7 +683,6 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 670 | snapshotAdmitted: make(map[*snapshotCapture]struct{}), | 683 | snapshotAdmitted: make(map[*snapshotCapture]struct{}), | ||
| 671 | snapshotWake: make(chan struct{}, 1), | 684 | snapshotWake: make(chan struct{}, 1), | ||
| 672 | notices: newNoticeCenter(), | 685 | notices: newNoticeCenter(), | ||
| 673 | - | remoteCatalog: newRemoteCatalogState(), | |||
| 674 | resumeParkGrace: defaultResumeParkGrace, | 686 | resumeParkGrace: defaultResumeParkGrace, | ||
| 675 | barScripts: &barScriptState{ | 687 | barScripts: &barScriptState{ | ||
| 676 | cfg: barConfigFromDomain(domain.Defaults().Bar), | 688 | cfg: barConfigFromDomain(domain.Defaults().Bar), | ||
| @@ -730,10 +742,14 @@ func New(ptys ports.PTYFactory, clock ports.Clock, log *slog.Logger, opts ...Opt | |||||
| 730 | d.lastAllocatedCreatedAt = maxCreatedAt | 742 | d.lastAllocatedCreatedAt = maxCreatedAt | ||
| 731 | } | 743 | } | ||
| 732 | d.mruSeq.Store(maxSeq) | 744 | d.mruSeq.Store(maxSeq) | ||
| 733 | - | d.loadRemoteCatalogCache() | |||
| 734 | return d | 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 | // Serve runs the accept loop over l, owning it for the loop's lifetime. It | 753 | // Serve runs the accept loop over l, owning it for the loop's lifetime. It | ||
| 738 | // returns when the last session is removed or ctx is cancelled; on the latter | 754 | // returns when the last session is removed or ctx is cancelled; on the latter | ||
| 739 | // path attached clients are detached with ReasonServerShutdown. | 755 | // path attached clients are detached with ReasonServerShutdown. | ||
| @@ -743,6 +759,49 @@ func (d *Daemon) Serve(ctx context.Context, l ports.ServerListener) error { | |||||
| 743 | d.hardCtx, d.hardCancel = context.WithCancel(context.Background()) | 759 | d.hardCtx, d.hardCancel = context.WithCancel(context.Background()) | ||
| 744 | defer d.hardCancel() | 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 | d.sessWg.Go(func() { | 805 | d.sessWg.Go(func() { | ||
| 747 | d.attentionAnimator(d.serveCtx) | 806 | d.attentionAnimator(d.serveCtx) | ||
| 748 | }) | 807 | }) | ||
| @@ -855,7 +914,6 @@ func (d *Daemon) purgeAll(reason uint8) (checkpointIncomplete bool) { | |||||
| 855 | } | 914 | } | ||
| 856 | 915 | ||||
| 857 | func (d *Daemon) terminateAllWithSnapshotDeadline(reason uint8, purge bool, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) { | 916 | func (d *Daemon) terminateAllWithSnapshotDeadline(reason uint8, purge bool, deadline *snapshotShutdownDeadline) (checkpointIncomplete bool) { | ||
| 858 | - | d.cancelRemoteDiscoveryRefresh() | |||
| 859 | d.closeMoveLifecycles() | 917 | d.closeMoveLifecycles() | ||
| 860 | d.mu.Lock() | 918 | d.mu.Lock() | ||
| 861 | d.closing = true | 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 | rt.pickerMu.Lock() | 196 | rt.pickerMu.Lock() | ||
| 197 | updated := rt.picker == observedModel && rt.pickerGeneration == observedGeneration && rt.pickerRefreshSequence == refreshSequence && rt.pickerIntent == intent && rt.pickerSource == source | 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 | if updated { | 200 | if updated { | ||
| 201 | + | before, haveBefore = rt.picker.Selected() | |||
| 199 | rt.picker.ReplaceFrom(model) | 202 | rt.picker.ReplaceFrom(model) | ||
| 200 | if intent == pickerNavigate && opts.nearestRow >= 0 { | 203 | if intent == pickerNavigate && opts.nearestRow >= 0 { | ||
| 201 | rt.picker.SelectNearestRow(opts.nearestRow) | 204 | rt.picker.SelectNearestRow(opts.nearestRow) | ||
| 202 | } | 205 | } | ||
| 203 | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | 206 | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | ||
| 207 | + | after, haveAfter = rt.picker.Selected() | |||
| 204 | } | 208 | } | ||
| 205 | rt.pickerMu.Unlock() | 209 | rt.pickerMu.Unlock() | ||
| 206 | - | if updated { | 210 | + | if !updated { |
| 207 | - | d.registerPreviewForSelection(ac) | 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 | pickerESC pendingByteTimer | 39 | pickerESC pendingByteTimer | ||
| 40 | 40 | ||||
| 41 | // Test-only, unsynchronized lifecycle seams. Assign them before picker | 41 | // Test-only, unsynchronized lifecycle seams. Assign them before picker | ||
| 42 | - | // publication or goroutine startup. Hooks run without pickerMu or | 42 | + | // publication or goroutine startup. |
| 43 | - | // remoteCatalog.mu held. | 43 | + | afterPickerRefreshBuild func(*picker.Model) |
| 44 | - | beforeRemotePickerRegistration func() | |||
| 45 | - | afterPickerRefreshBuild func(*picker.Model) | |||
| 46 | 44 | ||||
| 47 | paletteMu sync.Mutex | 45 | paletteMu sync.Mutex | ||
| 48 | palette *palette.Model | 46 | palette *palette.Model | ||
M internal/usecase/daemon/palette.go +52 -46
| @@ -7,6 +7,7 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | renderer "github.com/bnema/vev-vt" | 8 | renderer "github.com/bnema/vev-vt" | ||
| 9 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | "github.com/bnema/vev/internal/protocol" | 11 | "github.com/bnema/vev/internal/protocol" | ||
| 11 | "github.com/bnema/vev/internal/protocol/catalogue" | 12 | "github.com/bnema/vev/internal/protocol/catalogue" | ||
| 12 | "github.com/bnema/vev/internal/usecase/command" | 13 | "github.com/bnema/vev/internal/usecase/command" | ||
| @@ -56,12 +57,8 @@ func (d *Daemon) enterPalette(sess *session, ac *attachedClient) { | |||||
| 56 | ac.overlays.palettePreview = "" | 57 | ac.overlays.palettePreview = "" | ||
| 57 | ac.overlays.paletteFeedback = "" | 58 | ac.overlays.paletteFeedback = "" | ||
| 58 | ac.overlays.palettePending = nil | 59 | ac.overlays.palettePending = nil | ||
| 59 | - | generation := ac.overlays.paletteGeneration | |||
| 60 | ac.overlays.paletteMu.Unlock() | 60 | ac.overlays.paletteMu.Unlock() | ||
| 61 | d.invalidateRender(sess, ac, true, "palette.go") | 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 | type paletteSessionIdentity struct { | 64 | type paletteSessionIdentity struct { | ||
| @@ -118,9 +115,10 @@ func routeEntryForRef(snapshot protocol.RecentRouteSnapshot, ref protocol.RouteR | |||||
| 118 | return protocol.RecentRouteEntry{}, false | 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 { | 118 | + | func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, currentLifecycle domain.SessionLifecycleID, hosts []ports.RemoteHostSnapshot) []palette.Result { |
| 122 | - | results := make([]palette.Result, 0, len(remoteCatalog)+2) | 119 | + | results := make([]palette.Result, 0, len(hosts)+2) |
| 123 | servingOrigin := "" | 120 | servingOrigin := "" | ||
| 121 | + | servingEndpoint := "" | |||
| 124 | if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal { | 122 | if home, ok := routeEntryForRef(snapshot, snapshot.Home); ok && home.Kind == protocol.RouteKindLocal { | ||
| 125 | results = append(results, palette.NewCreateSessionDestination( | 123 | results = append(results, palette.NewCreateSessionDestination( | ||
| 126 | palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation, | 124 | palette.CreateSessionOnLocalRoute, "", "", snapshot.Home, snapshot.Generation, | ||
| @@ -128,28 +126,42 @@ func createSessionDestinationResults(snapshot protocol.RecentRouteSnapshot, curr | |||||
| 128 | } | 126 | } | ||
| 129 | if active, ok := activeRouteEntryForLifecycle(snapshot, currentLifecycle); ok && active.Kind == protocol.RouteKindRemote { | 127 | if active, ok := activeRouteEntryForLifecycle(snapshot, currentLifecycle); ok && active.Kind == protocol.RouteKindRemote { | ||
| 130 | servingOrigin = paletteRemoteDisplayOrigin(active.HostLabel) | 128 | servingOrigin = paletteRemoteDisplayOrigin(active.HostLabel) | ||
| 129 | + | servingEndpoint = servingRouteEndpoint(hosts, active.Target.LifecycleID) | |||
| 131 | results = append(results, palette.NewCreateSessionDestination( | 130 | results = append(results, palette.NewCreateSessionDestination( | ||
| 132 | palette.CreateSessionOnServingDaemon, servingOrigin, "", protocol.RouteRef{}, 0, | 131 | palette.CreateSessionOnServingDaemon, servingOrigin, "", protocol.RouteRef{}, 0, | ||
| 133 | )) | 132 | )) | ||
| 134 | } | 133 | } | ||
| 135 | - | for _, host := range remoteCatalog { | 134 | + | for _, host := range hosts { |
| 136 | - | if host.status != remoteHostFresh || host.entry.Host == "" { | 135 | + | if host.Endpoint == "" { |
| 137 | - | continue | |||
| 138 | - | } | |||
| 139 | - | if _, configured := hostRanks[host.entry.Host]; !configured { | |||
| 140 | continue | 136 | continue | ||
| 141 | } | 137 | } | ||
| 142 | - | displayOrigin := paletteRemoteDisplayOrigin(host.entry.Host) | 138 | + | displayOrigin := paletteRemoteDisplayOrigin(host.Endpoint) |
| 143 | - | if servingOrigin != "" && displayOrigin == servingOrigin { | 139 | + | if servingEndpoint != "" && host.Endpoint == servingEndpoint { |
| 144 | continue | 140 | continue | ||
| 145 | } | 141 | } | ||
| 146 | results = append(results, palette.NewCreateSessionDestination( | 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 | return results | 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 | func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool { | 165 | func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemonDisplayOrigin string) bool { | ||
| 154 | if entry.Kind == protocol.RouteKindLocal { | 166 | if entry.Kind == protocol.RouteKindLocal { | ||
| 155 | return true | 167 | return true | ||
| @@ -158,11 +170,16 @@ func paletteRouteRepresentsDaemonSession(entry protocol.RecentRouteEntry, daemon | |||||
| 158 | paletteRemoteDisplayOrigin(entry.HostLabel) == daemonDisplayOrigin | 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 | if session.State == catalogue.RemoteCatalogSessionBroken { | 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 | // paletteResults captures eligible named sessions before paletteMu so the | 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 | daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle) | 205 | daemonDisplayOrigin := paletteDaemonDisplayOrigin(routeSnapshot, currentLifecycle) | ||
| 189 | 206 | ||||
| 190 | - | remoteCatalog := d.remoteCatalogSnapshot() | 207 | + | directory := d.remoteDirectorySnapshot() |
| 191 | - | hostRanks := d.remoteHostRanks() | 208 | + | hosts := append([]ports.RemoteHostSnapshot(nil), directory.Hosts...) |
| 192 | - | sortRemoteCatalog(remoteCatalog, hostRanks) | 209 | + | sortDirectoryHosts(hosts) |
| 193 | remoteSessionCount := 0 | 210 | remoteSessionCount := 0 | ||
| 194 | - | for _, host := range remoteCatalog { | 211 | + | for _, host := range hosts { |
| 195 | - | remoteSessionCount += len(host.entry.Sessions) | 212 | + | remoteSessionCount += len(host.Sessions) |
| 196 | } | 213 | } | ||
| 197 | 214 | ||||
| 198 | - | destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle, remoteCatalog, hostRanks) | 215 | + | destinations := createSessionDestinationResults(routeSnapshot, currentLifecycle, hosts) |
| 199 | results := make([]palette.Result, 0, len(commands)+len(destinations)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries)) | 216 | results := make([]palette.Result, 0, len(commands)+len(destinations)+len(sessions)+len(stopped)+remoteSessionCount+len(routeSnapshot.Entries)) | ||
| 200 | for _, cmd := range commands { | 217 | for _, cmd := range commands { | ||
| 201 | results = append(results, palette.NewCommandResult(cmd)) | 218 | results = append(results, palette.NewCommandResult(cmd)) | ||
| @@ -232,13 +249,13 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 232 | } | 249 | } | ||
| 233 | discovered := make([]discoveredRemote, 0, remoteSessionCount) | 250 | discovered := make([]discoveredRemote, 0, remoteSessionCount) | ||
| 234 | discoveredByPresentation := make(map[remotePalettePresentationIdentity][]paletteSessionIdentity, remoteSessionCount) | 251 | discoveredByPresentation := make(map[remotePalettePresentationIdentity][]paletteSessionIdentity, remoteSessionCount) | ||
| 235 | - | for _, host := range remoteCatalog { | 252 | + | for _, host := range hosts { |
| 236 | - | for _, session := range host.entry.Sessions { | 253 | + | for _, session := range host.Sessions { |
| 237 | if session.Ephemeral { | 254 | if session.Ephemeral { | ||
| 238 | continue | 255 | continue | ||
| 239 | } | 256 | } | ||
| 240 | key, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{ | 257 | key, target := remoteCatalogSessionTarget(domain.RemoteSessionKey{ | ||
| 241 | - | Host: host.entry.Host, Name: session.Name, | 258 | + | Host: host.Endpoint, Name: session.Name, |
| 242 | }, session) | 259 | }, session) | ||
| 243 | if key.Validate() != nil || target.Validate() != nil { | 260 | if key.Validate() != nil || target.Validate() != nil { | ||
| 244 | continue | 261 | continue | ||
| @@ -249,7 +266,7 @@ func (d *Daemon) paletteResults(current *session, commands []command.Command, ro | |||||
| 249 | } | 266 | } | ||
| 250 | discovered = append(discovered, discoveredRemote{ | 267 | discovered = append(discovered, discoveredRemote{ | ||
| 251 | identity: identity, | 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 | discoveredByPresentation[presentation] = append(discoveredByPresentation[presentation], identity) | 271 | discoveredByPresentation[presentation] = append(discoveredByPresentation[presentation], identity) | ||
| 255 | } | 272 | } | ||
| @@ -408,7 +425,6 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 408 | var generation uint64 | 425 | var generation uint64 | ||
| 409 | var rawQuery string | 426 | var rawQuery string | ||
| 410 | changed, cancel, execute, chooseDestination := false, false, false, false | 427 | changed, cancel, execute, chooseDestination := false, false, false, false | ||
| 411 | - | var closedDiscovery remoteDiscoveryInstance | |||
| 412 | var effect *attachmentEffect | 428 | var effect *attachmentEffect | ||
| 413 | if len(effects) != 0 { | 429 | if len(effects) != 0 { | ||
| 414 | effect = effects[0] | 430 | effect = effects[0] | ||
| @@ -530,11 +546,10 @@ func (d *Daemon) handlePaletteInput(ac *attachedClient, data []byte, effects ... | |||||
| 530 | } | 546 | } | ||
| 531 | } | 547 | } | ||
| 532 | if cancel { | 548 | if cancel { | ||
| 533 | - | closedDiscovery = ac.clearPaletteLocked() | 549 | + | ac.clearPaletteLocked() |
| 534 | } | 550 | } | ||
| 535 | ac.overlays.paletteMu.Unlock() | 551 | ac.overlays.paletteMu.Unlock() | ||
| 536 | if cancel { | 552 | if cancel { | ||
| 537 | - | d.remoteDiscoveryClosed(closedDiscovery) | |||
| 538 | d.invalidateRender(entry, ac, true, "palette.go") | 553 | d.invalidateRender(entry, ac, true, "palette.go") | ||
| 539 | return | 554 | return | ||
| 540 | } | 555 | } | ||
| @@ -715,8 +730,7 @@ func paletteArgs(query string, cmd command.Command) []string { | |||||
| 715 | return action.Args | 730 | return action.Args | ||
| 716 | } | 731 | } | ||
| 717 | 732 | ||||
| 718 | - | func (ac *attachedClient) clearPaletteLocked() remoteDiscoveryInstance { | 733 | + | func (ac *attachedClient) clearPaletteLocked() { |
| 719 | - | model := ac.overlays.palette | |||
| 720 | ac.overlays.paletteGeneration++ | 734 | ac.overlays.paletteGeneration++ | ||
| 721 | ac.overlays.palette = nil | 735 | ac.overlays.palette = nil | ||
| 722 | ac.overlays.paletteRouteSnapshot = protocol.RecentRouteSnapshot{} | 736 | ac.overlays.paletteRouteSnapshot = protocol.RecentRouteSnapshot{} | ||
| @@ -724,9 +738,6 @@ func (ac *attachedClient) clearPaletteLocked() remoteDiscoveryInstance { | |||||
| 724 | ac.overlays.palettePreview = "" | 738 | ac.overlays.palettePreview = "" | ||
| 725 | ac.overlays.paletteFeedback = "" | 739 | ac.overlays.paletteFeedback = "" | ||
| 726 | ac.overlays.palettePending = nil | 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 | func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) { | 743 | func (ac *attachedClient) paletteFailure(generation uint64, rawQuery, feedback string) { | ||
| @@ -744,9 +755,8 @@ func (d *Daemon) closeExecutedPalette(ac *attachedClient, generation uint64, raw | |||||
| 744 | ac.overlays.paletteMu.Unlock() | 755 | ac.overlays.paletteMu.Unlock() | ||
| 745 | return false | 756 | return false | ||
| 746 | } | 757 | } | ||
| 747 | - | instance := ac.clearPaletteLocked() | 758 | + | ac.clearPaletteLocked() |
| 748 | ac.overlays.paletteMu.Unlock() | 759 | ac.overlays.paletteMu.Unlock() | ||
| 749 | - | d.remoteDiscoveryClosed(instance) | |||
| 750 | return true | 760 | return true | ||
| 751 | } | 761 | } | ||
| 752 | 762 | ||||
| @@ -763,9 +773,8 @@ func (d *Daemon) closePaletteIfCurrent(ac *attachedClient, generation uint64) bo | |||||
| 763 | ac.overlays.paletteMu.Unlock() | 773 | ac.overlays.paletteMu.Unlock() | ||
| 764 | return false | 774 | return false | ||
| 765 | } | 775 | } | ||
| 766 | - | instance := ac.clearPaletteLocked() | 776 | + | ac.clearPaletteLocked() |
| 767 | ac.overlays.paletteMu.Unlock() | 777 | ac.overlays.paletteMu.Unlock() | ||
| 768 | - | d.remoteDiscoveryClosed(instance) | |||
| 769 | return true | 778 | return true | ||
| 770 | } | 779 | } | ||
| 771 | 780 | ||||
| @@ -927,13 +936,10 @@ func (d *Daemon) remoteCreateHostReady(endpoint string) bool { | |||||
| 927 | if d == nil || domain.ValidateRemoteHostTarget(endpoint) != nil { | 936 | if d == nil || domain.ValidateRemoteHostTarget(endpoint) != nil { | ||
| 928 | return false | 937 | return false | ||
| 929 | } | 938 | } | ||
| 930 | - | ranks := d.remoteHostRanks() | 939 | + | // Registration is creation authority: any registered endpoint is |
| 931 | - | if _, ok := ranks[endpoint]; !ok { | 940 | + | // attemptable at any inventory age. Freshness renders; the destination |
| 932 | - | return false | 941 | + | // rejects precisely on exact identity. |
| 933 | - | } | 942 | + | _, ok := d.remoteDirectorySnapshot().Find(endpoint) |
| 934 | - | d.remoteCatalog.mu.Lock() | |||
| 935 | - | defer d.remoteCatalog.mu.Unlock() | |||
| 936 | - | _, ok := d.remoteCatalogEntryLocked(endpoint) | |||
| 937 | return ok | 943 | return ok | ||
| 938 | } | 944 | } | ||
| 939 | 945 | ||||
M internal/usecase/daemon/palette_test.go +90 -87
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | renderer "github.com/bnema/vev-vt" | 12 | renderer "github.com/bnema/vev-vt" | ||
| 13 | "github.com/bnema/vev/internal/domain" | 13 | "github.com/bnema/vev/internal/domain" | ||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 15 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 15 | "github.com/bnema/vev/internal/protocol" | 16 | "github.com/bnema/vev/internal/protocol" | ||
| 16 | "github.com/bnema/vev/internal/protocol/catalogue" | 17 | "github.com/bnema/vev/internal/protocol/catalogue" | ||
| @@ -62,15 +63,15 @@ func TestCreateSessionDestinationResultsUseStructuredRouteAuthority(t *testing.T | |||||
| 62 | Target: testRouteTarget("home", 2), Name: "home", | 63 | Target: testRouteTarget("home", 2), Name: "home", | ||
| 63 | }}, | 64 | }}, | ||
| 64 | } | 65 | } | ||
| 65 | - | catalog := []remoteCatalogPresentationEntry{ | 66 | + | hosts := []ports.RemoteHostSnapshot{ |
| 66 | - | {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-a"}, status: remoteHostFresh}, | 67 | + | {Endpoint: "host-a", Sessions: []catalogue.RemoteCatalogSession{{Name: "work", LifecycleID: lifecycle}}}, |
| 67 | - | {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-b"}, status: remoteHostFresh}, | 68 | + | {Endpoint: "host-b"}, |
| 68 | - | {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-c"}, status: remoteHostStale}, | 69 | + | {Endpoint: "host-c"}, |
| 69 | - | {entry: catalogue.RemoteCatalogCacheEntry{Host: "host-d"}, status: remoteHostFresh}, | 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 | + | results := createSessionDestinationResults(snapshot, lifecycle, hosts) |
| 73 | - | require.Len(t, results, 3) | 74 | + | require.Len(t, results, 5) |
| 74 | _, kind, _, endpoint, route, ok := results[0].CreateSessionDestination() | 75 | _, kind, _, endpoint, route, ok := results[0].CreateSessionDestination() | ||
| 75 | require.True(t, ok) | 76 | require.True(t, ok) | ||
| 76 | require.Equal(t, palette.CreateSessionOnLocalRoute, kind) | 77 | require.Equal(t, palette.CreateSessionOnLocalRoute, kind) | ||
| @@ -89,6 +90,13 @@ func TestCreateSessionDestinationResultsUseStructuredRouteAuthority(t *testing.T | |||||
| 89 | require.Equal(t, palette.CreateSessionOnRemoteHost, kind) | 90 | require.Equal(t, palette.CreateSessionOnRemoteHost, kind) | ||
| 90 | require.Equal(t, "host-b", origin) | 91 | require.Equal(t, "host-b", origin) | ||
| 91 | require.Equal(t, "host-b", endpoint) | 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 | func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) { | 102 | func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) { | ||
| @@ -99,6 +107,38 @@ func TestRemoteCreateSessionDestinationRequiresAttachmentEffect(t *testing.T) { | |||||
| 99 | require.ErrorIs(t, err, errCreateDestinationUnavailable) | 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 | func TestCreateSessionDestinationResultsDoNotInferLocalFromRemoteHome(t *testing.T) { | 142 | func TestCreateSessionDestinationResultsDoNotInferLocalFromRemoteHome(t *testing.T) { | ||
| 103 | lifecycle := domain.SessionLifecycleID{1} | 143 | lifecycle := domain.SessionLifecycleID{1} | ||
| 104 | snapshot := protocol.RecentRouteSnapshot{ | 144 | snapshot := protocol.RecentRouteSnapshot{ | ||
| @@ -110,7 +150,7 @@ func TestCreateSessionDestinationResultsDoNotInferLocalFromRemoteHome(t *testing | |||||
| 110 | Target: protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "work"}, Name: "work", | 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 | require.Len(t, results, 1) | 154 | require.Len(t, results, 1) | ||
| 115 | _, kind, _, _, _, ok := results[0].CreateSessionDestination() | 155 | _, kind, _, _, _, ok := results[0].CreateSessionDestination() | ||
| 116 | require.True(t, ok) | 156 | require.True(t, ok) | ||
| @@ -508,16 +548,10 @@ func TestPaletteIncludesExactRemoteCatalogTargetBesideSameNameLocalSession(t *te | |||||
| 508 | local := addControlSession(d, "vev", "tab-2", "pane-2") | 548 | local := addControlSession(d, "vev", "tab-2", "pane-2") | ||
| 509 | local.ephemeral = false | 549 | local.ephemeral = false | ||
| 510 | remoteLifecycle := domain.SessionLifecycleID{21} | 550 | remoteLifecycle := domain.SessionLifecycleID{21} | ||
| 511 | - | d.remoteCatalog.mu.Lock() | 551 | + | seedRemoteDirectory(t, d, reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{ |
| 512 | - | d.remoteCatalog.cache["user@arch"] = catalogue.RemoteCatalogCacheEntry{ | 552 | + | LifecycleID: remoteLifecycle, Name: "vev", State: catalogue.RemoteCatalogSessionUp, |
| 513 | - | Host: "user@arch", FetchedAt: now, | 553 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-remote", Index: 0, Name: "shell"}}, ActiveTabID: "tab-remote", |
| 514 | - | Sessions: []catalogue.RemoteCatalogSession{{ | 554 | + | })) |
| 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() | |||
| 521 | 555 | ||||
| 522 | results := d.paletteResults(current, nil, protocol.RecentRouteSnapshot{}) | 556 | results := d.paletteResults(current, nil, protocol.RecentRouteSnapshot{}) | ||
| 523 | var matching []palette.Result | 557 | var matching []palette.Result | ||
| @@ -595,18 +629,14 @@ func TestPaletteMatchesRecentRemoteRouteToCatalog(t *testing.T) { | |||||
| 595 | current := addControlSession(d, "current", "tab-1", "pane-1") | 629 | current := addControlSession(d, "current", "tab-1", "pane-1") | ||
| 596 | current.ephemeral = false | 630 | current.ephemeral = false | ||
| 597 | lifecycle := domain.SessionLifecycleID{22} | 631 | lifecycle := domain.SessionLifecycleID{22} | ||
| 598 | - | d.remoteCatalog.mu.Lock() | 632 | + | hosts := make([]ports.RemoteHostSnapshot, 0, len(test.catalogEndpoints)) |
| 599 | for _, endpoint := range test.catalogEndpoints { | 633 | for _, endpoint := range test.catalogEndpoints { | ||
| 600 | - | d.remoteCatalog.cache[endpoint] = catalogue.RemoteCatalogCacheEntry{ | 634 | + | hosts = append(hosts, reachableDirectoryHost(endpoint, now, catalogue.RemoteCatalogSession{ |
| 601 | - | Host: endpoint, FetchedAt: now, | 635 | + | LifecycleID: lifecycle, Name: "vev", State: catalogue.RemoteCatalogSessionUp, |
| 602 | - | Sessions: []catalogue.RemoteCatalogSession{{ | 636 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev", |
| 603 | - | LifecycleID: lifecycle, Name: "vev", State: catalogue.RemoteCatalogSessionUp, | 637 | + | })) |
| 604 | - | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev", | |||
| 605 | - | }}, | |||
| 606 | - | } | |||
| 607 | - | d.remoteCatalog.status[endpoint] = remoteHostFresh | |||
| 608 | } | 638 | } | ||
| 609 | - | d.remoteCatalog.mu.Unlock() | 639 | + | seedRemoteDirectory(t, d, hosts...) |
| 610 | 640 | ||||
| 611 | results := d.paletteResults(current, nil, protocol.RecentRouteSnapshot{ | 641 | results := d.paletteResults(current, nil, protocol.RecentRouteSnapshot{ | ||
| 612 | Generation: 2, | 642 | Generation: 2, | ||
| @@ -646,16 +676,10 @@ func TestPaletteRemoteCatalogSelectionSendsExactAttachTarget(t *testing.T) { | |||||
| 646 | now := time.Unix(1_000, 0) | 676 | now := time.Unix(1_000, 0) | ||
| 647 | d.clock = fixedRemoteRefreshClock{now: now} | 677 | d.clock = fixedRemoteRefreshClock{now: now} | ||
| 648 | remoteLifecycle := domain.SessionLifecycleID{31} | 678 | remoteLifecycle := domain.SessionLifecycleID{31} | ||
| 649 | - | d.remoteCatalog.mu.Lock() | 679 | + | seedRemoteDirectory(t, d, reachableDirectoryHost("user@arch", now, catalogue.RemoteCatalogSession{ |
| 650 | - | d.remoteCatalog.cache["user@arch"] = catalogue.RemoteCatalogCacheEntry{ | 680 | + | LifecycleID: remoteLifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, |
| 651 | - | Host: "user@arch", FetchedAt: now, | 681 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-work", Index: 0, Name: "shell"}}, ActiveTabID: "tab-work", |
| 652 | - | Sessions: []catalogue.RemoteCatalogSession{{ | 682 | + | })) |
| 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() | |||
| 659 | token := beginRecentRoutePaletteEffect(t, d, current, ac) | 683 | token := beginRecentRoutePaletteEffect(t, d, current, ac) | ||
| 660 | 684 | ||||
| 661 | d.handleInputForAttachment(token, []byte("\x1b ")) | 685 | d.handleInputForAttachment(token, []byte("\x1b ")) | ||
| @@ -674,27 +698,20 @@ func TestPaletteRemoteCatalogSelectionSendsExactAttachTarget(t *testing.T) { | |||||
| 674 | RemoteTarget: &remoteTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | 698 | RemoteTarget: &remoteTarget, EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | ||
| 675 | }, target) | 699 | }, target) | ||
| 676 | require.False(t, ac.overlays.paletteActive()) | 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 | p, release := newBlockingPTY(t) | 704 | p, release := newBlockingPTY(t) | ||
| 684 | defer release() | 705 | defer release() | ||
| 685 | d, current, ac, sends := newManualSessionWithPTYs(t, p) | 706 | d, current, ac, sends := newManualSessionWithPTYs(t, p) | ||
| 686 | now := time.Unix(1_000, 0) | 707 | now := time.Unix(1_000, 0) | ||
| 687 | d.clock = fixedRemoteRefreshClock{now: now} | 708 | d.clock = fixedRemoteRefreshClock{now: now} | ||
| 688 | - | d.remoteCatalog.mu.Lock() | 709 | + | // Unknown host with no inventory offers no remote session rows: there |
| 689 | - | d.remoteCatalog.cache["arch"] = catalogue.RemoteCatalogCacheEntry{ | 710 | + | // is no exact target to validate, so the palette stays open without |
| 690 | - | Host: "arch", FetchedAt: now, | 711 | + | // emitting a handoff or switching the source session. |
| 691 | - | Sessions: []catalogue.RemoteCatalogSession{{ | 712 | + | seedRemoteDirectory(t, d, ports.RemoteHostSnapshot{ |
| 692 | - | LifecycleID: domain.SessionLifecycleID{32}, Name: "cached", State: catalogue.RemoteCatalogSessionUp, | 713 | + | Endpoint: "arch", Availability: domain.RemoteAvailabilityUnknown, Checking: true, |
| 693 | - | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-cached", Index: 0}}, ActiveTabID: "tab-cached", | 714 | + | }) |
| 694 | - | }}, | |||
| 695 | - | } | |||
| 696 | - | d.remoteCatalog.status["arch"] = remoteHostCached | |||
| 697 | - | d.remoteCatalog.mu.Unlock() | |||
| 698 | token := beginRecentRoutePaletteEffect(t, d, current, ac) | 715 | token := beginRecentRoutePaletteEffect(t, d, current, ac) | ||
| 699 | 716 | ||||
| 700 | d.handleInputForAttachment(token, []byte("\x1b ")) | 717 | d.handleInputForAttachment(token, []byte("\x1b ")) | ||
| @@ -703,10 +720,6 @@ func TestPaletteCachedRemoteSelectionFailsClosed(t *testing.T) { | |||||
| 703 | 720 | ||||
| 704 | require.Same(t, current, ac.currentAttachmentSession()) | 721 | require.Same(t, current, ac.currentAttachmentSession()) | ||
| 705 | require.True(t, ac.overlays.paletteActive()) | 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 | for { | 723 | for { | ||
| 711 | select { | 724 | select { | ||
| 712 | case frame := <-sends: | 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) { | 733 | + | func TestRemoteDirectoryUpdatesOpenPaletteAndPreservesQuery(t *testing.T) { |
| 721 | - | hosts := &remoteRefreshHostStore{hosts: []string{"user@arch"}} | 734 | + | d := newRemotePickerDaemon() |
| 722 | - | d, catalog, cache := newRemoteRefreshDaemon(t, hosts, time.Unix(1_000, 0)) | 735 | + | seedRemoteDirectory(t, d) |
| 723 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "current") | 736 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "current") | ||
| 724 | 737 | ||||
| 725 | d.enterPalette(sess, ac) | 738 | d.enterPalette(sess, ac) | ||
| 726 | - | request := receiveRemotePicker(t, catalog.requests, "palette catalog request") | |||
| 727 | ac.overlays.paletteMu.Lock() | 739 | ac.overlays.paletteMu.Lock() | ||
| 728 | model := ac.overlays.palette | 740 | model := ac.overlays.palette | ||
| 741 | + | require.NotNil(t, model) | |||
| 729 | for _, r := range "vev" { | 742 | for _, r := range "vev" { | ||
| 730 | model.Insert(r) | 743 | model.Insert(r) | ||
| 731 | } | 744 | } | ||
| 732 | ac.overlays.paletteMu.Unlock() | 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 | LifecycleID: domain.SessionLifecycleID{41}, Name: "vev", State: catalogue.RemoteCatalogSessionUp, | 750 | LifecycleID: domain.SessionLifecycleID{41}, Name: "vev", State: catalogue.RemoteCatalogSessionUp, | ||
| 735 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev", | 751 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-vev", Index: 0}}, ActiveTabID: "tab-vev", | ||
| 736 | - | })} | 752 | + | })) |
| 737 | - | receiveRemotePicker(t, cache.stores, "palette catalog cache store") | 753 | + | d.refreshPalette(ac) |
| 738 | - | d.sessWg.Wait() | |||
| 739 | 754 | ||||
| 740 | ac.overlays.paletteMu.Lock() | 755 | ac.overlays.paletteMu.Lock() | ||
| 741 | require.Same(t, model, ac.overlays.palette) | 756 | require.Same(t, model, ac.overlays.palette) | ||
| @@ -749,39 +764,27 @@ func TestRemoteRefreshUpdatesOpenPaletteAndPreservesQuery(t *testing.T) { | |||||
| 749 | require.Contains(t, displayTexts, "Switch to session vev@arch") | 764 | require.Contains(t, displayTexts, "Switch to session vev@arch") | ||
| 750 | 765 | ||||
| 751 | d.closePalette(ac) | 766 | d.closePalette(ac) | ||
| 752 | - | d.remoteCatalog.mu.Lock() | 767 | + | require.False(t, ac.overlays.paletteActive(), "closing the palette releases view state only") |
| 753 | - | require.NotContains(t, d.remoteCatalog.consumers, ac) | |||
| 754 | - | require.Nil(t, d.remoteCatalog.cancel) | |||
| 755 | - | d.remoteCatalog.mu.Unlock() | |||
| 756 | } | 768 | } | ||
| 757 | 769 | ||||
| 758 | - | func TestRemoteRefreshTracksPickerAndPaletteOnSameAttachment(t *testing.T) { | 770 | + | func TestRemoteDirectoryTracksPickerAndPaletteOnSameAttachment(t *testing.T) { |
| 759 | - | hosts := &remoteRefreshHostStore{hosts: []string{"arch"}} | 771 | + | d := newRemotePickerDaemon() |
| 760 | - | d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(1_100, 0)) | 772 | + | seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(1_100, 0), directorySessionForTest("work"))) |
| 761 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | 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 | d.publishPicker(sess, ac, d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | 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 | d.enterPalette(sess, ac) | 778 | d.enterPalette(sess, ac) | ||
| 766 | - | receiveRemotePickerClose(t, request.ctx.Done(), "superseded picker catalog request") | 779 | + | require.True(t, ac.overlays.pickerActive()) |
| 767 | - | request = receiveRemotePicker(t, catalog.requests, "shared catalog request") | 780 | + | require.True(t, ac.overlays.paletteActive()) |
| 768 | 781 | ||||
| 769 | d.closePalette(ac) | 782 | d.closePalette(ac) | ||
| 770 | - | select { | 783 | + | require.False(t, ac.overlays.paletteActive()) |
| 771 | - | case <-request.ctx.Done(): | 784 | + | require.True(t, ac.overlays.pickerActive(), "closing the palette must not disturb the open picker") |
| 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() | |||
| 778 | 785 | ||||
| 779 | d.closePicker(ac) | 786 | d.closePicker(ac) | ||
| 780 | - | receiveRemotePickerClose(t, request.ctx.Done(), "last discovery consumer cancellation") | 787 | + | require.False(t, ac.overlays.pickerActive()) |
| 781 | - | d.remoteCatalog.mu.Lock() | |||
| 782 | - | require.Empty(t, d.remoteCatalog.consumers) | |||
| 783 | - | require.Nil(t, d.remoteCatalog.cancel) | |||
| 784 | - | d.remoteCatalog.mu.Unlock() | |||
| 785 | } | 788 | } | ||
| 786 | 789 | ||||
| 787 | func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { | 790 | func TestPaletteResultsDeduplicateByLifecycleAndKeepEqualLabels(t *testing.T) { | ||
M internal/usecase/daemon/picker.go +53 -56
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | renderer "github.com/bnema/vev-vt" | 10 | renderer "github.com/bnema/vev-vt" | ||
| 11 | "github.com/bnema/vev/internal/domain" | 11 | "github.com/bnema/vev/internal/domain" | ||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 12 | "github.com/bnema/vev/internal/protocol" | 13 | "github.com/bnema/vev/internal/protocol" | ||
| 13 | "github.com/bnema/vev/internal/protocol/catalogue" | 14 | "github.com/bnema/vev/internal/protocol/catalogue" | ||
| 14 | "github.com/bnema/vev/internal/usecase/keys" | 15 | "github.com/bnema/vev/internal/usecase/keys" | ||
| @@ -75,7 +76,6 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker. | |||||
| 75 | rt.pickerPreview = nil | 76 | rt.pickerPreview = nil | ||
| 76 | rt.picker = model | 77 | rt.picker = model | ||
| 77 | rt.pickerGeneration++ | 78 | rt.pickerGeneration++ | ||
| 78 | - | instance := remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: model} | |||
| 79 | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | 79 | rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load())) | ||
| 80 | rt.pickerIntent = intent | 80 | rt.pickerIntent = intent | ||
| 81 | rt.pickerSource = source | 81 | rt.pickerSource = source | ||
| @@ -85,10 +85,6 @@ func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker. | |||||
| 85 | d.teardownPreviewSubscription(ac, previous, previousGeneration) | 85 | d.teardownPreviewSubscription(ac, previous, previousGeneration) | ||
| 86 | d.registerPreviewForSelection(ac) | 86 | d.registerPreviewForSelection(ac) | ||
| 87 | d.invalidateRender(sess, ac, true, "picker.go") | 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 | // pickerViews captures one canonical lifecycle/tab snapshot. It intentionally | 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() | 166 | + | directory := d.remoteDirectorySnapshot() |
| 171 | - | sortRemoteCatalog(catalog, d.remoteHostRanks()) | 167 | + | monitored := d.remoteDirectory != nil |
| 168 | + | hosts := append([]ports.RemoteHostSnapshot(nil), directory.Hosts...) | |||
| 169 | + | sortDirectoryHosts(hosts) | |||
| 170 | + | now := d.daemonNow() | |||
| 172 | 171 | ||||
| 173 | catalogRows := 0 | 172 | catalogRows := 0 | ||
| 174 | - | for _, host := range catalog { | 173 | + | for _, host := range hosts { |
| 175 | - | catalogRows += len(host.entry.Sessions) | 174 | + | catalogRows += len(host.Sessions) |
| 176 | - | if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) { | 175 | + | if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable { |
| 177 | catalogRows++ | 176 | catalogRows++ | ||
| 178 | } | 177 | } | ||
| 179 | } | 178 | } | ||
| @@ -187,33 +186,43 @@ func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.Session | |||||
| 187 | views = append(views, view) | 186 | views = append(views, view) | ||
| 188 | } | 187 | } | ||
| 189 | publishedRemote := false | 188 | publishedRemote := false | ||
| 190 | - | for _, host := range catalog { | 189 | + | for _, host := range hosts { |
| 191 | publishedForHost := 0 | 190 | publishedForHost := 0 | ||
| 192 | - | for _, session := range host.entry.Sessions { | 191 | + | for _, session := range host.Sessions { |
| 193 | - | key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name} | 192 | + | key := domain.RemoteSessionKey{Host: host.Endpoint, Name: session.Name} |
| 194 | if key.Validate() != nil { | 193 | if key.Validate() != nil { | ||
| 195 | continue | 194 | continue | ||
| 196 | } | 195 | } | ||
| 197 | - | view := remotePickerView(key, session, host.status, host.entry.FetchedAt) | 196 | + | view := remotePickerView(key, session, host, now) |
| 198 | if grouped { | 197 | if grouped { | ||
| 199 | view.HideRemoteOrigin = true | 198 | view.HideRemoteOrigin = true | ||
| 200 | if publishedForHost == 0 { | 199 | if publishedForHost == 0 { | ||
| 201 | - | view.Section = "REMOTE " + host.entry.Host | 200 | + | view.Section = "REMOTE " + host.Endpoint |
| 202 | } | 201 | } | ||
| 203 | } | 202 | } | ||
| 204 | views = append(views, view) | 203 | views = append(views, view) | ||
| 205 | publishedForHost++ | 204 | publishedForHost++ | ||
| 206 | publishedRemote = true | 205 | publishedRemote = true | ||
| 207 | } | 206 | } | ||
| 208 | - | if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) { | 207 | + | if len(host.Sessions) == 0 && host.Availability != domain.RemoteAvailabilityReachable { |
| 209 | - | view := remotePickerHostView(host.entry.Host, host.status) | 208 | + | view := remotePickerHostView(host, now) |
| 210 | if grouped { | 209 | if grouped { | ||
| 211 | - | view.Section = "REMOTE " + host.entry.Host | 210 | + | view.Section = "REMOTE " + host.Endpoint |
| 212 | } | 211 | } | ||
| 213 | views = append(views, view) | 212 | views = append(views, view) | ||
| 214 | publishedRemote = true | 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 | for i, s := range stopped { | 226 | for i, s := range stopped { | ||
| 218 | createdAt := s.createdAt | 227 | createdAt := s.createdAt | ||
| 219 | view := picker.SessionView{ | 228 | view := picker.SessionView{ | ||
| @@ -253,7 +262,7 @@ func attentionSuffix(label string) string { | |||||
| 253 | func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Target) { | 262 | func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Target) { | ||
| 254 | reason := target.UnavailableReason | 263 | reason := target.UnavailableReason | ||
| 255 | if reason == "" { | 264 | if reason == "" { | ||
| 256 | - | reason = "identity_changed" | 265 | + | reason = domain.RemoteReasonIdentityChanged |
| 257 | } | 266 | } | ||
| 258 | message := "Remote session unavailable" | 267 | message := "Remote session unavailable" | ||
| 259 | if target.RemoteTarget != nil { | 268 | if target.RemoteTarget != nil { | ||
| @@ -267,21 +276,23 @@ func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Targ | |||||
| 267 | 276 | ||||
| 268 | func remotePickerReasonText(reason string) string { | 277 | func remotePickerReasonText(reason string) string { | ||
| 269 | switch reason { | 278 | switch reason { | ||
| 270 | - | case "catalog_stale": | 279 | + | case domain.RemoteReasonCatalogStale: |
| 271 | return "catalog stale" | 280 | return "catalog stale" | ||
| 272 | - | case "host_unreachable": | 281 | + | case domain.RemoteReasonHostUnreachable: |
| 273 | return "host unreachable" | 282 | return "host unreachable" | ||
| 274 | - | case "version_mismatch": | 283 | + | case domain.RemoteReasonVersionMismatch: |
| 275 | return "version mismatch" | 284 | return "version mismatch" | ||
| 276 | - | case "session_stopped": | 285 | + | case domain.RemoteReasonSessionStopped: |
| 277 | return "session stopped" | 286 | return "session stopped" | ||
| 278 | - | case "session_broken": | 287 | + | case domain.RemoteReasonSessionBroken: |
| 279 | return "session broken" | 288 | return "session broken" | ||
| 280 | - | case "malformed": | 289 | + | case domain.RemoteReasonMalformed: |
| 281 | return "catalog malformed" | 290 | return "catalog malformed" | ||
| 282 | - | case "refreshing": | 291 | + | case domain.RemoteReasonAuthFailure: |
| 292 | + | return "authentication failed" | |||
| 293 | + | case domain.RemoteReasonRefreshing: | |||
| 283 | return "refreshing" | 294 | return "refreshing" | ||
| 284 | - | case "identity_changed": | 295 | + | case domain.RemoteReasonIdentityChanged: |
| 285 | return "session identity changed" | 296 | return "session identity changed" | ||
| 286 | default: | 297 | default: | ||
| 287 | return "unavailable" | 298 | return "unavailable" | ||
| @@ -314,7 +325,6 @@ func pickerSearchSlashIndex(data, pending []byte) int { | |||||
| 314 | func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | 325 | func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | ||
| 315 | rt := ac.overlays | 326 | rt := ac.overlays | ||
| 316 | var previewGeneration uint64 | 327 | var previewGeneration uint64 | ||
| 317 | - | var instance remotePickerInstance | |||
| 318 | return listInputState{ | 328 | return listInputState{ | ||
| 319 | pending: &rt.pickerPending, | 329 | pending: &rt.pickerPending, | ||
| 320 | esc: &rt.pickerESC, | 330 | esc: &rt.pickerESC, | ||
| @@ -324,7 +334,6 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||||
| 324 | unlock: rt.pickerMu.Unlock, | 334 | unlock: rt.pickerMu.Unlock, | ||
| 325 | active: func() bool { return rt.picker != nil }, | 335 | active: func() bool { return rt.picker != nil }, | ||
| 326 | closeLocked: func() { | 336 | closeLocked: func() { | ||
| 327 | - | instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker} | |||
| 328 | rt.picker = nil | 337 | rt.picker = nil | ||
| 329 | rt.pickerIntent = pickerNavigate | 338 | rt.pickerIntent = pickerNavigate | ||
| 330 | rt.pickerSource = moveSourceLocator{} | 339 | rt.pickerSource = moveSourceLocator{} | ||
| @@ -332,7 +341,6 @@ func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState { | |||||
| 332 | }, | 341 | }, | ||
| 333 | afterClose: func() { | 342 | afterClose: func() { | ||
| 334 | d.clearPreviewGeneration(ac, previewGeneration) | 343 | d.clearPreviewGeneration(ac, previewGeneration) | ||
| 335 | - | d.remoteDiscoveryClosed(instance.discoveryInstance()) | |||
| 336 | if sess := ac.currentAttachmentSession(); sess != nil { | 344 | if sess := ac.currentAttachmentSession(); sess != nil { | ||
| 337 | d.invalidateRender(sess, ac, true, "picker.go") | 345 | d.invalidateRender(sess, ac, true, "picker.go") | ||
| 338 | } | 346 | } | ||
| @@ -550,7 +558,7 @@ func (d *Daemon) registerPreviewForSelection(ac *attachedClient) { | |||||
| 550 | ac.overlays.pickerMu.Lock() | 558 | ac.overlays.pickerMu.Lock() | ||
| 551 | // Selection may have changed while the target was resolved. | 559 | // Selection may have changed while the target was resolved. | ||
| 552 | selected, stillSelected := ac.overlays.picker.Selected() | 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 | if valid { | 562 | if valid { | ||
| 555 | ac.overlays.pickerPreview = next | 563 | ac.overlays.pickerPreview = next | ||
| 556 | ac.overlays.pickerPreviewSession = targetSess | 564 | ac.overlays.pickerPreviewSession = targetSess | ||
| @@ -633,7 +641,7 @@ func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Targ | |||||
| 633 | matching := false | 641 | matching := false | ||
| 634 | if ac.overlays.pickerPreviewGeneration == generation && ac.overlays.picker != nil { | 642 | if ac.overlays.pickerPreviewGeneration == generation && ac.overlays.picker != nil { | ||
| 635 | selected, stillSelected := ac.overlays.picker.Selected() | 643 | selected, stillSelected := ac.overlays.picker.Selected() | ||
| 636 | - | matching = stillSelected && pickerTargetsEqual(selected, target) | 644 | + | matching = stillSelected && pickerRouteTargetsEqual(selected, target) |
| 637 | if matching { | 645 | if matching { | ||
| 638 | ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, "remote preview unavailable") | 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 | return | 667 | return | ||
| 660 | } | 668 | } | ||
| 661 | selected, stillSelected := ac.overlays.picker.Selected() | 669 | selected, stillSelected := ac.overlays.picker.Selected() | ||
| 662 | - | if !stillSelected || !pickerTargetsEqual(selected, target) { | 670 | + | if !stillSelected || !pickerRouteTargetsEqual(selected, target) { |
| 663 | ac.overlays.pickerMu.Unlock() | 671 | ac.overlays.pickerMu.Unlock() | ||
| 664 | return | 672 | return | ||
| 665 | } | 673 | } | ||
| @@ -819,7 +827,6 @@ func (d *Daemon) closePickerIfCurrentRefresh(ac *attachedClient, model *picker.M | |||||
| 819 | ac.overlays.pickerMu.Unlock() | 827 | ac.overlays.pickerMu.Unlock() | ||
| 820 | return false | 828 | return false | ||
| 821 | } | 829 | } | ||
| 822 | - | instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker} | |||
| 823 | ac.overlays.picker = nil | 830 | ac.overlays.picker = nil | ||
| 824 | ac.overlays.pickerTitle = "" | 831 | ac.overlays.pickerTitle = "" | ||
| 825 | ac.overlays.pickerIntent = pickerNavigate | 832 | ac.overlays.pickerIntent = pickerNavigate | ||
| @@ -829,16 +836,16 @@ func (d *Daemon) closePickerIfCurrentRefresh(ac *attachedClient, model *picker.M | |||||
| 829 | previewGeneration := ac.overlays.pickerPreviewGeneration | 836 | previewGeneration := ac.overlays.pickerPreviewGeneration | ||
| 830 | ac.overlays.pickerMu.Unlock() | 837 | ac.overlays.pickerMu.Unlock() | ||
| 831 | d.clearPreviewGeneration(ac, previewGeneration) | 838 | d.clearPreviewGeneration(ac, previewGeneration) | ||
| 832 | - | if instance.model != nil { | |||
| 833 | - | d.remoteDiscoveryClosed(instance.discoveryInstance()) | |||
| 834 | - | } | |||
| 835 | return true | 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 | if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name || | 846 | if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name || | ||
| 840 | left.RemoteHost != right.RemoteHost || left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped || | 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 | !remoteTargetsEqual(left.RemoteTarget, right.RemoteTarget) { | 849 | !remoteTargetsEqual(left.RemoteTarget, right.RemoteTarget) { | ||
| 843 | return false | 850 | return false | ||
| 844 | } | 851 | } | ||
| @@ -1107,29 +1114,19 @@ func (d *Daemon) sendRemoteAttachTargetForAttachment(effect *attachmentEffect, t | |||||
| 1107 | return nil | 1114 | return nil | ||
| 1108 | } | 1115 | } | ||
| 1109 | 1116 | ||||
| 1110 | - | func (d *Daemon) remoteCatalogEntryLocked(host string) (catalogue.RemoteCatalogCacheEntry, bool) { | 1117 | + | // remoteCatalogTargetReady validates an exact remote destination against |
| 1111 | - | if d.remoteCatalog.status[host] != remoteHostFresh { | 1118 | + | // the latest known inventory at any age. Freshness is presentation |
| 1112 | - | return catalogue.RemoteCatalogCacheEntry{}, false | 1119 | + | // information, not attach authority: valid cached targets are attempted and |
| 1113 | - | } | 1120 | + | // rejected precisely at the destination, never declared nonexistent by age. |
| 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 | - | ||||
| 1122 | func (d *Daemon) remoteCatalogTargetReady(target domain.RemoteSessionTarget) bool { | 1121 | func (d *Daemon) remoteCatalogTargetReady(target domain.RemoteSessionTarget) bool { | ||
| 1123 | if d == nil || target.Validate() != nil { | 1122 | if d == nil || target.Validate() != nil { | ||
| 1124 | return false | 1123 | return false | ||
| 1125 | } | 1124 | } | ||
| 1126 | - | d.remoteCatalog.mu.Lock() | 1125 | + | host, ok := d.remoteDirectorySnapshot().Find(target.Endpoint) |
| 1127 | - | defer d.remoteCatalog.mu.Unlock() | 1126 | + | if !ok || !host.InventoryKnown { |
| 1128 | - | entry, ok := d.remoteCatalogEntryLocked(target.Endpoint) | |||
| 1129 | - | if !ok { | |||
| 1130 | return false | 1127 | return false | ||
| 1131 | } | 1128 | } | ||
| 1132 | - | for _, session := range entry.Sessions { | 1129 | + | for _, session := range host.Sessions { |
| 1133 | if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID { | 1130 | if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID { | ||
| 1134 | continue | 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 | func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) { | 22 | func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) { | ||
| 23 | - | d := newRemotePickerDaemon(nil) | 23 | + | d := newRemotePickerDaemon() |
| 24 | source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source") | 24 | source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source") | ||
| 25 | target, _, _ := addRemoteRefreshPickerOwner(t, d, "target") | 25 | target, _, _ := addRemoteRefreshPickerOwner(t, d, "target") | ||
| 26 | target.incarnation = remoteLifecycleForTest() | 26 | target.incarnation = remoteLifecycleForTest() | ||
| @@ -41,7 +41,7 @@ func TestLocalPickerOfferCarriesExactLifecycle(t *testing.T) { | |||||
| 41 | } | 41 | } | ||
| 42 | 42 | ||||
| 43 | func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) { | 43 | func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) { | ||
| 44 | - | d := newRemotePickerDaemon(nil) | 44 | + | d := newRemotePickerDaemon() |
| 45 | source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source") | 45 | source, ac, sends := addRemoteRefreshPickerOwner(t, d, "source") | ||
| 46 | lifecycle := remoteLifecycleForTest() | 46 | lifecycle := remoteLifecycleForTest() | ||
| 47 | d.inactive["stopped"] = inactiveSession{ | 47 | d.inactive["stopped"] = inactiveSession{ | ||
| @@ -74,16 +74,11 @@ func TestStoppedLocalPickerHandoffWaitsForClientClose(t *testing.T) { | |||||
| 74 | } | 74 | } | ||
| 75 | 75 | ||||
| 76 | func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) { | 76 | func TestRemotePickerRichHandoffCarriesLifecycleTabAndPolicy(t *testing.T) { | ||
| 77 | - | d := newRemotePickerDaemon(nil) | 77 | + | d := newRemotePickerDaemon() |
| 78 | lifecycle := remoteLifecycleForTest() | 78 | lifecycle := remoteLifecycleForTest() | ||
| 79 | - | d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{ | 79 | + | seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(10, 0), catalogue.RemoteCatalogSession{ |
| 80 | - | Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{ | 80 | + | LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, |
| 81 | - | LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, | 81 | + | })) |
| 82 | - | }}, | |||
| 83 | - | }}) | |||
| 84 | - | d.remoteCatalog.mu.Lock() | |||
| 85 | - | d.remoteCatalog.status["arch"] = remoteHostFresh | |||
| 86 | - | d.remoteCatalog.mu.Unlock() | |||
| 87 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | 82 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | ||
| 88 | token := sess.captureAttachmentCapability(ac, ac.transport()) | 83 | token := sess.captureAttachmentCapability(ac, ac.transport()) | ||
| 89 | effect, admitted := ac.beginAttachmentEffect(token) | 84 | effect, admitted := ac.beginAttachmentEffect(token) | ||
| @@ -115,17 +110,12 @@ func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) { | |||||
| 115 | }}, | 110 | }}, | ||
| 116 | } { | 111 | } { | ||
| 117 | t.Run(test.name, func(t *testing.T) { | 112 | t.Run(test.name, func(t *testing.T) { | ||
| 118 | - | d := newRemotePickerDaemon(nil) | 113 | + | d := newRemotePickerDaemon() |
| 119 | lifecycle := remoteLifecycleForTest() | 114 | lifecycle := remoteLifecycleForTest() | ||
| 120 | - | d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{ | 115 | + | seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(10, 0), catalogue.RemoteCatalogSession{ |
| 121 | - | Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{ | 116 | + | LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, |
| 122 | - | LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, | 117 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}}, |
| 123 | - | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}}, | 118 | + | })) |
| 124 | - | }}, | |||
| 125 | - | }}) | |||
| 126 | - | d.remoteCatalog.mu.Lock() | |||
| 127 | - | d.remoteCatalog.status["arch"] = remoteHostFresh | |||
| 128 | - | d.remoteCatalog.mu.Unlock() | |||
| 129 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | 119 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | ||
| 130 | token := sess.captureAttachmentCapability(ac, ac.transport()) | 120 | token := sess.captureAttachmentCapability(ac, ac.transport()) | ||
| 131 | effect, admitted := ac.beginAttachmentEffect(token) | 121 | effect, admitted := ac.beginAttachmentEffect(token) | ||
| @@ -151,18 +141,13 @@ func TestRemotePickerRichHandoffRejectsMismatchedRouteKey(t *testing.T) { | |||||
| 151 | } | 141 | } | ||
| 152 | 142 | ||||
| 153 | func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) { | 143 | func TestRemotePickerRichHandoffRejectsReplacedLifecycle(t *testing.T) { | ||
| 154 | - | d := newRemotePickerDaemon(nil) | 144 | + | d := newRemotePickerDaemon() |
| 155 | lifecycle := remoteLifecycleForTest() | 145 | lifecycle := remoteLifecycleForTest() | ||
| 156 | cachedLifecycle := lifecycle | 146 | cachedLifecycle := lifecycle | ||
| 157 | cachedLifecycle[0]++ | 147 | cachedLifecycle[0]++ | ||
| 158 | - | d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{ | 148 | + | seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(10, 0), catalogue.RemoteCatalogSession{ |
| 159 | - | Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{ | 149 | + | LifecycleID: cachedLifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, |
| 160 | - | LifecycleID: cachedLifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, | 150 | + | })) |
| 161 | - | }}, | |||
| 162 | - | }}) | |||
| 163 | - | d.remoteCatalog.mu.Lock() | |||
| 164 | - | d.remoteCatalog.status["arch"] = remoteHostFresh | |||
| 165 | - | d.remoteCatalog.mu.Unlock() | |||
| 166 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | 151 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | ||
| 167 | token := sess.captureAttachmentCapability(ac, ac.transport()) | 152 | token := sess.captureAttachmentCapability(ac, ac.transport()) | ||
| 168 | effect, admitted := ac.beginAttachmentEffect(token) | 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 | "github.com/bnema/vev/internal/ports" | 12 | "github.com/bnema/vev/internal/ports" | ||
| 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 13 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 14 | "github.com/bnema/vev/internal/protocol" | 14 | "github.com/bnema/vev/internal/protocol" | ||
| 15 | - | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 16 | "github.com/bnema/vev/internal/protocol/wire" | 15 | "github.com/bnema/vev/internal/protocol/wire" | ||
| 17 | "github.com/bnema/vev/internal/usecase/palette" | 16 | "github.com/bnema/vev/internal/usecase/palette" | ||
| 18 | ) | 17 | ) | ||
| 19 | 18 | ||||
| 20 | func TestPaletteRemoteCreateUsesDaemonEnvironment(t *testing.T) { | 19 | func TestPaletteRemoteCreateUsesDaemonEnvironment(t *testing.T) { | ||
| 21 | - | local := newRemotePickerDaemon(&remoteRefreshHostStore{hosts: []string{"remote.example"}}) | 20 | + | local := newRemotePickerDaemon() |
| 22 | - | local.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{Host: "remote.example", FetchedAt: time.Unix(10, 0)}}) | 21 | + | seedRemoteDirectory(t, local, reachableDirectoryHost("remote.example", time.Unix(10, 0))) |
| 23 | - | local.remoteCatalog.mu.Lock() | |||
| 24 | - | local.remoteCatalog.status["remote.example"] = remoteHostFresh | |||
| 25 | - | local.remoteCatalog.mu.Unlock() | |||
| 26 | sess, ac, sends := addRemoteRefreshPickerOwner(t, local, "local") | 22 | sess, ac, sends := addRemoteRefreshPickerOwner(t, local, "local") | ||
| 27 | effect := beginRecentRoutePaletteEffect(t, local, sess, ac) | 23 | effect := beginRecentRoutePaletteEffect(t, local, sess, ac) | ||
| 28 | result := palette.NewCreateSessionDestination(palette.CreateSessionOnRemoteHost, "remote.example", "remote.example", protocol.RouteRef{}, 0) | 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 | package daemon | 1 | package daemon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | - | "context" | |||
| 5 | "encoding/base64" | 4 | "encoding/base64" | ||
| 6 | - | "errors" | |||
| 7 | "fmt" | 5 | "fmt" | ||
| 8 | "math" | 6 | "math" | ||
| 9 | - | "sort" | |||
| 10 | "time" | 7 | "time" | ||
| 11 | 8 | ||||
| 12 | "github.com/bnema/vev/internal/domain" | 9 | "github.com/bnema/vev/internal/domain" | ||
| 10 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | "github.com/bnema/vev/internal/protocol/catalogue" | 11 | "github.com/bnema/vev/internal/protocol/catalogue" | ||
| 14 | - | "github.com/bnema/vev/internal/usecase/palette" | |||
| 15 | "github.com/bnema/vev/internal/usecase/picker" | 12 | "github.com/bnema/vev/internal/usecase/picker" | ||
| 16 | ) | 13 | ) | ||
| 17 | 14 | ||||
| 18 | - | type remoteCatalogPresentationEntry struct { | 15 | + | // directoryInventoryStaleAge is the presentation freshness threshold: past |
| 19 | - | entry catalogue.RemoteCatalogCacheEntry | 16 | + | // this age inventory renders stale, but staleness never gates activation. |
| 20 | - | status remoteHostStatus | 17 | + | const directoryInventoryStaleAge = 30 * time.Second |
| 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 | - | } | |||
| 140 | 18 | ||||
| 141 | func remoteSessionStateStopped(state catalogue.RemoteCatalogSessionState) bool { | 19 | func remoteSessionStateStopped(state catalogue.RemoteCatalogSessionState) bool { | ||
| 142 | return state == catalogue.RemoteCatalogSessionDown | 20 | return state == catalogue.RemoteCatalogSessionDown | ||
| @@ -177,9 +55,17 @@ func remoteCatalogSessionTarget(key domain.RemoteSessionKey, session catalogue.R | |||||
| 177 | return key, target | 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 | key, target := remoteCatalogSessionTarget(key, session) | 68 | key, target := remoteCatalogSessionTarget(key, session) | ||
| 182 | - | availability := remotePickerAvailability(status) | |||
| 183 | stopped := remoteSessionStateStopped(session.State) | 69 | stopped := remoteSessionStateStopped(session.State) | ||
| 184 | broken := session.State == catalogue.RemoteCatalogSessionBroken | 70 | broken := session.State == catalogue.RemoteCatalogSessionBroken | ||
| 185 | tabs := catalogue.CatalogTabs(session) | 71 | tabs := catalogue.CatalogTabs(session) | ||
| @@ -206,14 +92,14 @@ func remotePickerView(key domain.RemoteSessionKey, session catalogue.RemoteCatal | |||||
| 206 | // must remain possible, while sendRemoteAttachTargetForAttachment will | 92 | // must remain possible, while sendRemoteAttachTargetForAttachment will | ||
| 207 | // fail closed on the invalid target. | 93 | // fail closed on the invalid target. | ||
| 208 | remoteTarget := &target | 94 | remoteTarget := &target | ||
| 209 | - | reason := remoteReasonForStatus(status) | 95 | + | reason := directorySessionReason(host, session, target) |
| 210 | targetValid := remoteTarget.Validate() == nil | 96 | targetValid := remoteTarget.Validate() == nil | ||
| 211 | activation := picker.RemoteUnavailable | 97 | activation := picker.RemoteUnavailable | ||
| 212 | - | if broken { | 98 | + | if !broken && targetValid && host.Availability != domain.RemoteAvailabilityIncompatible { |
| 213 | - | reason = "session_broken" | 99 | + | // Known inventory is attemptable at any age: freshness is |
| 214 | - | } else if !targetValid { | 100 | + | // presentation information, not attach authority. Exact session, |
| 215 | - | reason = "identity_changed" | 101 | + | // lifecycle and tab validation still applies at submit, and the |
| 216 | - | } else if status == remoteHostFresh { | 102 | + | // destination rejects precisely. |
| 217 | if stopped { | 103 | if stopped { | ||
| 218 | activation = picker.RemoteRestart | 104 | activation = picker.RemoteRestart | ||
| 219 | } else { | 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 | return picker.SessionView{ | 110 | return picker.SessionView{ | ||
| 242 | ID: key.ID(), | 111 | ID: key.ID(), | ||
| 243 | Name: key.Display(), | 112 | Name: key.Display(), | ||
| 244 | RemoteKey: &key, | 113 | RemoteKey: &key, | ||
| 245 | RemoteTarget: remoteTarget, | 114 | RemoteTarget: remoteTarget, | ||
| 246 | RemoteHost: key.Host, | 115 | RemoteHost: key.Host, | ||
| 247 | - | RemoteAvailability: availability, | 116 | + | RemoteAvailability: directoryPickerAvailability(host, now), |
| 248 | - | RemoteDetail: detail, | 117 | + | RemoteDetail: directoryPickerDetail(host, session, reason, activation, now), |
| 249 | RemoteReason: reason, | 118 | RemoteReason: reason, | ||
| 250 | RemoteActivation: activation, | 119 | RemoteActivation: activation, | ||
| 251 | Tabs: viewTabs, | 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 { | 127 | + | // directorySessionReason labels a session row for display. It never gates |
| 259 | - | switch status { | 128 | + | // activation except through broken, invalid, or incompatible states; age and |
| 260 | - | case remoteHostRefreshing: | 129 | + | // observation failures render while the row stays attemptable. |
| 261 | - | return "refreshing" | 130 | + | func directorySessionReason(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession, target domain.RemoteSessionTarget) string { |
| 262 | - | case remoteHostStale: | 131 | + | if session.State == catalogue.RemoteCatalogSessionBroken { |
| 263 | - | return "catalog_stale" | 132 | + | return domain.RemoteReasonSessionBroken |
| 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 | - | }) | |||
| 299 | } | 133 | } | ||
| 300 | - | sort.Slice(entries, func(i, j int) bool { | 134 | + | if target.Validate() != nil { |
| 301 | - | left, right := entries[i].entry.Host, entries[j].entry.Host | 135 | + | return domain.RemoteReasonIdentityChanged |
| 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) | |||
| 317 | } | 136 | } | ||
| 318 | - | } | 137 | + | switch host.Availability { |
| 319 | - | 138 | + | case domain.RemoteAvailabilityReachable: | |
| 320 | - | // registerRemoteDiscoveryConsumer installs one exact overlay generation as a | 139 | + | if host.Checking { |
| 321 | - | // refresh owner. Overlay ownership always nests its model lock before the | 140 | + | return domain.RemoteReasonRefreshing |
| 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 | |||
| 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 | default: | 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) { | 156 | + | func directoryPickerAvailability(host ports.RemoteHostSnapshot, now time.Time) picker.RemoteAvailability { |
| 357 | - | if d == nil || instance.ac == nil || instance.ac.overlays == nil { | 157 | + | switch host.Availability { |
| 358 | - | return | 158 | + | case domain.RemoteAvailabilityReachable: |
| 359 | - | } | 159 | + | if directoryInventoryStale(host, now) { |
| 360 | - | rt := instance.ac.overlays | 160 | + | return picker.RemoteStale |
| 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 | |||
| 374 | } | 161 | } | ||
| 375 | - | rt.paletteMu.Lock() | 162 | + | return picker.RemoteFresh |
| 376 | - | unlock = rt.paletteMu.Unlock | 163 | + | case domain.RemoteAvailabilityIncompatible: |
| 377 | - | current = rt.paletteGeneration == instance.generation && rt.palette == nil | 164 | + | return picker.RemoteVersionMismatch |
| 165 | + | case domain.RemoteAvailabilityUnknown: | |||
| 166 | + | return picker.RemoteCached | |||
| 378 | default: | 167 | default: | ||
| 379 | - | return | 168 | + | if host.Checking { |
| 380 | - | } | 169 | + | return picker.RemoteCached |
| 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) | |||
| 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 | 175 | + | func directoryPickerDetail(host ports.RemoteHostSnapshot, session catalogue.RemoteCatalogSession, reason string, activation picker.RemoteActivation, now time.Time) string { |
| 452 | - | // launches one bounded catalog call per known host. Installing the generation | 176 | + | if host.Checking { |
| 453 | - | // is gated by the exact overlay generation that registered ownership. Host | 177 | + | return "checking remote…" |
| 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 | |||
| 478 | } | 178 | } | ||
| 479 | - | var previous context.CancelFunc | 179 | + | stopped := remoteSessionStateStopped(session.State) |
| 480 | - | var generation uint64 | 180 | + | broken := session.State == catalogue.RemoteCatalogSessionBroken |
| 481 | - | var installed bool | 181 | + | switch host.Availability { |
| 482 | - | switch instance.kind { | 182 | + | case domain.RemoteAvailabilityReachable: |
| 483 | - | case remoteDiscoveryPicker: | 183 | + | if !host.LastSuccess.IsZero() && directoryInventoryStale(host, now) { |
| 484 | - | if instance.picker == nil { | 184 | + | return "catalog stale since " + host.LastSuccess.Format(time.RFC3339) |
| 485 | - | cancel() | |||
| 486 | - | return 0 | |||
| 487 | } | 185 | } | ||
| 488 | - | rt.pickerMu.Lock() | 186 | + | case domain.RemoteAvailabilityUnreachable: |
| 489 | - | previous, generation, installed = install(rt.pickerGeneration == instance.generation && rt.picker == instance.picker) | 187 | + | if host.InventoryKnown && !host.LastSuccess.IsZero() { |
| 490 | - | rt.pickerMu.Unlock() | 188 | + | return "stale since " + host.LastSuccess.Format(time.RFC3339) |
| 491 | - | case remoteDiscoveryPalette: | |||
| 492 | - | if instance.palette == nil { | |||
| 493 | - | cancel() | |||
| 494 | - | return 0 | |||
| 495 | } | 189 | } | ||
| 496 | - | rt.paletteMu.Lock() | 190 | + | return "unreachable" |
| 497 | - | previous, generation, installed = install(rt.paletteGeneration == instance.generation && rt.palette == instance.palette) | 191 | + | case domain.RemoteAvailabilityIncompatible: |
| 498 | - | rt.paletteMu.Unlock() | 192 | + | return "version mismatch" |
| 193 | + | case domain.RemoteAvailabilityAuthFailed: | |||
| 194 | + | return "authentication failed" | |||
| 195 | + | case domain.RemoteAvailabilityInvalidResponse: | |||
| 196 | + | return "catalog malformed" | |||
| 499 | default: | 197 | default: | ||
| 500 | - | cancel() | 198 | + | return "checking remote…" |
| 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() | |||
| 546 | } | 199 | } | ||
| 547 | - | d.refreshRemoteDiscoveryConsumers() | 200 | + | switch { |
| 548 | - | 201 | + | case broken: | |
| 549 | - | for _, host := range hosts { | 202 | + | return "session broken" |
| 550 | - | d.sessWg.Go(func() { | 203 | + | case reason == domain.RemoteReasonIdentityChanged: |
| 551 | - | catalog, listErr := d.remoteCatalogClient.List(ctx, host) | 204 | + | return "identity changed" |
| 552 | - | d.applyRemoteRefreshResult(generation, host, catalog, listErr) | 205 | + | case activation == picker.RemoteRestart: |
| 553 | - | }) | 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 { | 216 | + | // remotePickerCheckingView is the non-actionable placeholder published |
| 559 | - | for _, host := range hosts { | 217 | + | // while the remote directory has never been initialized: it distinguishes |
| 560 | - | if host == target { | 218 | + | // "still checking remotes" from "no remotes configured". Enter on the |
| 561 | - | return true | 219 | + | // row fails safe through the usual unavailable-target path. |
| 562 | - | } | 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 | 229 | + | func remotePickerHostView(host ports.RemoteHostSnapshot, now time.Time) picker.SessionView { |
| 568 | - | // refresh generation and current host-registry membership remain authoritative. | 230 | + | return picker.SessionView{ |
| 569 | - | // Its return reports whether durable cache state changed. | 231 | + | ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host.Endpoint))), |
| 570 | - | func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalog catalogue.RemoteCatalog, listErr error) bool { | 232 | + | Name: host.Endpoint, |
| 571 | - | if d == nil { | 233 | + | RemoteHost: host.Endpoint, |
| 572 | - | return false | 234 | + | RemoteReason: directoryHostReason(host), |
| 573 | - | } | 235 | + | RemoteAvailability: directoryPickerAvailability(host, now), |
| 574 | - | d.remoteCatalog.mu.Lock() | 236 | + | RemoteDetail: directoryHostDetail(host), |
| 575 | - | current := d.remoteCatalog.refresh == generation | 237 | + | RemoteActivation: picker.RemoteUnavailable, |
| 576 | - | d.remoteCatalog.mu.Unlock() | 238 | + | CannotAcceptMoves: true, |
| 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 | - | } | |||
| 592 | } | 239 | } | ||
| 240 | + | } | |||
| 593 | 241 | ||||
| 594 | - | d.remoteCatalog.mu.Lock() | 242 | + | func directoryHostReason(host ports.RemoteHostSnapshot) string { |
| 595 | - | if d.remoteCatalog.refresh != generation { | 243 | + | switch host.Availability { |
| 596 | - | d.remoteCatalog.mu.Unlock() | 244 | + | case domain.RemoteAvailabilityReachable: |
| 597 | - | return false | 245 | + | return "" |
| 598 | - | } | 246 | + | case domain.RemoteAvailabilityUnreachable: |
| 599 | - | if !stillKnown { | 247 | + | return domain.RemoteReasonHostUnreachable |
| 600 | - | _, cacheChanged := d.remoteCatalog.cache[host] | 248 | + | case domain.RemoteAvailabilityIncompatible: |
| 601 | - | delete(d.remoteCatalog.cache, host) | 249 | + | return domain.RemoteReasonVersionMismatch |
| 602 | - | delete(d.remoteCatalog.status, host) | 250 | + | case domain.RemoteAvailabilityAuthFailed: |
| 603 | - | delete(d.remoteCatalog.failure, host) | 251 | + | return domain.RemoteReasonAuthFailure |
| 604 | - | d.remoteCatalog.mu.Unlock() | 252 | + | case domain.RemoteAvailabilityInvalidResponse: |
| 605 | - | if cacheChanged { | 253 | + | return domain.RemoteReasonMalformed |
| 606 | - | d.persistRemoteCatalogCache() | 254 | + | default: |
| 607 | - | d.refreshRemoteDiscoveryConsumers() | 255 | + | return domain.RemoteReasonRefreshing |
| 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 | |||
| 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() { | 259 | + | func directoryHostDetail(host ports.RemoteHostSnapshot) string { |
| 643 | - | if d == nil { | 260 | + | if host.Checking { |
| 644 | - | return | 261 | + | return "checking remote…" |
| 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}) | |||
| 654 | } | 262 | } | ||
| 655 | - | d.remoteCatalog.mu.Unlock() | 263 | + | switch host.Availability { |
| 656 | - | 264 | + | case domain.RemoteAvailabilityReachable: | |
| 657 | - | for _, consumer := range consumers { | 265 | + | return "no sessions" |
| 658 | - | ac := consumer.ac | 266 | + | case domain.RemoteAvailabilityUnreachable: |
| 659 | - | if ac == nil || ac.overlays == nil { | 267 | + | return "unreachable" |
| 660 | - | continue | 268 | + | case domain.RemoteAvailabilityIncompatible: |
| 661 | - | } | 269 | + | return "version mismatch" |
| 662 | - | refreshed := false | 270 | + | case domain.RemoteAvailabilityAuthFailed: |
| 663 | - | if consumer.kind&remoteDiscoveryPicker != 0 { | 271 | + | return "authentication failed" |
| 664 | - | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) | 272 | + | case domain.RemoteAvailabilityInvalidResponse: |
| 665 | - | refreshed = true | 273 | + | return "catalog malformed" |
| 666 | - | } | 274 | + | default: |
| 667 | - | if consumer.kind&remoteDiscoveryPalette != 0 { | 275 | + | return "checking remote…" |
| 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 | - | } | |||
| 677 | } | 276 | } | ||
| 678 | } | 277 | } | ||
M internal/usecase/daemon/remote_picker_test.go +315 -674
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| 16 | 16 | ||||
| 17 | "github.com/bnema/vev/internal/domain" | 17 | "github.com/bnema/vev/internal/domain" | ||
| 18 | + | "github.com/bnema/vev/internal/ports" | |||
| 18 | appports "github.com/bnema/vev/internal/ports" | 19 | appports "github.com/bnema/vev/internal/ports" | ||
| 19 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | 20 | portsmocks "github.com/bnema/vev/internal/ports/mocks" | ||
| 20 | "github.com/bnema/vev/internal/protocol" | 21 | "github.com/bnema/vev/internal/protocol" | ||
| @@ -57,8 +58,41 @@ func receiveRemotePickerClose(t *testing.T, ch <-chan struct{}, what string) { | |||||
| 57 | require.NoError(t, waitRemotePickerClose(ch, what)) | 58 | require.NoError(t, waitRemotePickerClose(ch, what)) | ||
| 58 | } | 59 | } | ||
| 59 | 60 | ||||
| 60 | - | func newRemotePickerDaemon(store appports.RemoteHostStore) *Daemon { | 61 | + | func newRemotePickerDaemon() *Daemon { |
| 61 | - | return New(nil, stubClock{}, slog.New(slog.NewTextHandler(io.Discard, nil)), WithRemoteDiscovery(store, nil, nil)) | 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 | func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) { | 98 | func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) { | ||
| @@ -68,7 +102,8 @@ func TestRemotePickerStoppedRowsUseCanonicalStateAndSafeSelection(t *testing.T) | |||||
| 68 | LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionDown, | 102 | LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionDown, | ||
| 69 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, | 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 | require.True(t, view.Stopped) | 107 | require.True(t, view.Stopped) | ||
| 73 | require.NotNil(t, view.RemoteTarget) | 108 | require.NotNil(t, view.RemoteTarget) | ||
| 74 | require.True(t, view.RemoteTarget.Stopped) | 109 | require.True(t, view.RemoteTarget.Stopped) | ||
| @@ -82,7 +117,7 @@ func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) { | |||||
| 82 | tabs[0] = catalogue.RemoteCatalogTab{Name: "main"} | 117 | tabs[0] = catalogue.RemoteCatalogTab{Name: "main"} | ||
| 83 | view := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, catalogue.RemoteCatalogSession{ | 118 | view := remotePickerView(domain.RemoteSessionKey{Host: "arch", Name: "work"}, catalogue.RemoteCatalogSession{ | ||
| 84 | LifecycleID: remoteLifecycleForTest(), Name: "work", State: "down", Tabs: tabs, | 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 | require.NotNil(t, view.RemoteTarget) | 122 | require.NotNil(t, view.RemoteTarget) | ||
| 88 | require.Equal(t, uint16(math.MaxUint16), view.RemoteTarget.StoppedTab.ExpectedCount) | 123 | require.Equal(t, uint16(math.MaxUint16), view.RemoteTarget.StoppedTab.ExpectedCount) | ||
| @@ -90,30 +125,28 @@ func TestRemotePickerClampsStoppedOrdinalTabCount(t *testing.T) { | |||||
| 90 | 125 | ||||
| 91 | func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) { | 126 | func TestRemotePickerUsesCompactUpDetailRegardlessOfAttachment(t *testing.T) { | ||
| 92 | for _, attached := range []bool{false, true} { | 127 | for _, attached := range []bool{false, true} { | ||
| 93 | - | view := remotePickerView(domain.RemoteSessionKey{Host: "remote", Name: "work"}, catalogue.RemoteCatalogSession{ | 128 | + | session := catalogue.RemoteCatalogSession{ |
| 94 | LifecycleID: remoteLifecycleForTest(), Name: "work", State: "up", Attached: attached, | 129 | LifecycleID: remoteLifecycleForTest(), Name: "work", State: "up", Attached: attached, | ||
| 95 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0}}, | 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 | require.Equal(t, "up", view.RemoteDetail) | 134 | require.Equal(t, "up", view.RemoteDetail) | ||
| 98 | } | 135 | } | ||
| 99 | } | 136 | } | ||
| 100 | 137 | ||||
| 101 | func TestRemotePickerScopesSameLifecycleBytesToEachEndpoint(t *testing.T) { | 138 | func TestRemotePickerScopesSameLifecycleBytesToEachEndpoint(t *testing.T) { | ||
| 102 | - | store := &remoteRefreshHostStore{hosts: []string{"remote", "vev@remote"}} | 139 | + | d := newRemotePickerDaemon() |
| 103 | - | d := newRemotePickerDaemon(store) | |||
| 104 | lifecycle := remoteLifecycleForTest() | 140 | lifecycle := remoteLifecycleForTest() | ||
| 105 | session := catalogue.RemoteCatalogSession{ | 141 | session := catalogue.RemoteCatalogSession{ | ||
| 106 | LifecycleID: lifecycle, Name: "work", State: "up", | 142 | LifecycleID: lifecycle, Name: "work", State: "up", | ||
| 107 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, | 143 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, | ||
| 108 | } | 144 | } | ||
| 109 | - | d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{ | 145 | + | fetchedAt := time.Unix(100, 0) |
| 110 | - | {Host: "remote", FetchedAt: time.Unix(100, 0), Sessions: []catalogue.RemoteCatalogSession{session}}, | 146 | + | seedRemoteDirectory(t, d, |
| 111 | - | {Host: "vev@remote", FetchedAt: time.Unix(100, 0), Sessions: []catalogue.RemoteCatalogSession{session}}, | 147 | + | reachableDirectoryHost("remote", fetchedAt, session), |
| 112 | - | }) | 148 | + | reachableDirectoryHost("vev@remote", fetchedAt, session), |
| 113 | - | d.remoteCatalog.mu.Lock() | 149 | + | ) |
| 114 | - | d.remoteCatalog.status["remote"] = remoteHostFresh | |||
| 115 | - | d.remoteCatalog.status["vev@remote"] = remoteHostFresh | |||
| 116 | - | d.remoteCatalog.mu.Unlock() | |||
| 117 | 150 | ||||
| 118 | views, _ := d.pickerViews(nil, nil) | 151 | views, _ := d.pickerViews(nil, nil) | ||
| 119 | var targets []*domain.RemoteSessionTarget | 152 | var targets []*domain.RemoteSessionTarget | ||
| @@ -127,52 +160,27 @@ func TestRemotePickerScopesSameLifecycleBytesToEachEndpoint(t *testing.T) { | |||||
| 127 | require.Equal(t, "vev@remote", targets[1].Endpoint) | 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 | now := time.Unix(100, 0) | 167 | now := time.Unix(100, 0) | ||
| 132 | - | clock := &remotePickerClock{now: now} | 168 | + | d := newTestDaemon(t, nil, &remotePickerClock{now: now}) |
| 133 | - | d := newTestDaemon(t, nil, clock) | |||
| 134 | lifecycle := remoteLifecycleForTest() | 169 | lifecycle := remoteLifecycleForTest() | ||
| 135 | - | d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{ | 170 | + | session := catalogue.RemoteCatalogSession{ |
| 136 | - | Host: "arch", FetchedAt: now.Add(-remoteCatalogAttachTTL), Sessions: []catalogue.RemoteCatalogSession{{ | 171 | + | LifecycleID: lifecycle, Name: "work", State: "up", |
| 137 | - | LifecycleID: lifecycle, Name: "work", State: "up", Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, | 172 | + | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1", Index: 0, Name: "main"}}, |
| 138 | - | }}, | 173 | + | ActiveTabID: "tab-1", |
| 139 | - | }}) | 174 | + | } |
| 140 | - | d.remoteCatalog.mu.Lock() | 175 | + | seedRemoteDirectory(t, d, reachableDirectoryHost("arch", now.Add(-24*time.Hour), session)) |
| 141 | - | d.remoteCatalog.status["arch"] = remoteHostFresh | 176 | + | |
| 142 | - | d.remoteCatalog.mu.Unlock() | |||
| 143 | - | ||||
| 144 | - | entries := d.remoteCatalogSnapshot() | |||
| 145 | - | require.Len(t, entries, 1) | |||
| 146 | - | require.Equal(t, remoteHostStale, entries[0].status) | |||
| 147 | target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", LiveTabID: "tab-1"} | 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") | 178 | + | require.True(t, d.remoteCatalogTargetReady(target), "known inventory stays attemptable at any age") |
| 149 | - | } | |||
| 150 | 179 | ||||
| 151 | - | func TestRemotePickerTargetReadyExpiresAfterPickerBuildWithoutSnapshot(t *testing.T) { | 180 | + | views, _ := d.pickerViews(nil, nil) |
| 152 | - | now := time.Unix(100, 0) | 181 | + | require.Len(t, views, 1) |
| 153 | - | clock := &remotePickerClock{now: now} | 182 | + | require.Equal(t, picker.RemoteAttach, views[0].RemoteActivation) |
| 154 | - | d := newTestDaemon(t, nil, clock) | 183 | + | require.Equal(t, picker.RemoteStale, views[0].RemoteAvailability) |
| 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() | |||
| 176 | } | 184 | } | ||
| 177 | 185 | ||||
| 178 | func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) { | 186 | func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) { | ||
| @@ -216,20 +224,16 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) { | |||||
| 216 | 224 | ||||
| 217 | for _, test := range tests { | 225 | for _, test := range tests { | ||
| 218 | t.Run(test.name, func(t *testing.T) { | 226 | t.Run(test.name, func(t *testing.T) { | ||
| 219 | - | d := newRemotePickerDaemon(nil) | 227 | + | d := newRemotePickerDaemon() |
| 220 | lifecycle := remoteLifecycleForTest() | 228 | lifecycle := remoteLifecycleForTest() | ||
| 221 | - | d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{ | 229 | + | session := catalogue.RemoteCatalogSession{ |
| 222 | - | Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{{ | 230 | + | LifecycleID: lifecycle, |
| 223 | - | LifecycleID: lifecycle, | 231 | + | Name: "vive", |
| 224 | - | Name: "vive", | 232 | + | State: "up", |
| 225 | - | State: "up", | 233 | + | Tabs: test.tabs, |
| 226 | - | Tabs: test.tabs, | 234 | + | ActiveTabID: test.activeTabID, |
| 227 | - | ActiveTabID: test.activeTabID, | 235 | + | } |
| 228 | - | }}, | 236 | + | seedRemoteDirectory(t, d, reachableDirectoryHost("arch", time.Unix(10, 0), session)) |
| 229 | - | }}) | |||
| 230 | - | d.remoteCatalog.mu.Lock() | |||
| 231 | - | d.remoteCatalog.status["arch"] = remoteHostFresh | |||
| 232 | - | d.remoteCatalog.mu.Unlock() | |||
| 233 | 237 | ||||
| 234 | views, _ := d.pickerViews(nil, nil) | 238 | views, _ := d.pickerViews(nil, nil) | ||
| 235 | require.Len(t, views, 1) | 239 | require.Len(t, views, 1) | ||
| @@ -247,69 +251,100 @@ func TestRemotePickerSelectsCatalogActiveTab(t *testing.T) { | |||||
| 247 | } | 251 | } | ||
| 248 | 252 | ||||
| 249 | func TestRemotePickerAvailabilityUsesCachedRowsForFailures(t *testing.T) { | 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 | tests := []struct { | 258 | tests := []struct { | ||
| 251 | - | name string | 259 | + | name string |
| 252 | - | status remoteHostStatus | 260 | + | host ports.RemoteHostSnapshot |
| 253 | - | want picker.RemoteAvailability | 261 | + | want picker.RemoteAvailability |
| 262 | + | wantActivate bool | |||
| 254 | }{ | 263 | }{ | ||
| 255 | - | {name: "cached", status: remoteHostCached, want: picker.RemoteCached}, | 264 | + | { |
| 256 | - | {name: "unreachable", status: remoteHostUnreachable, want: picker.RemoteStale}, | 265 | + | name: "unknown renders cached and stays attemptable", |
| 257 | - | {name: "version mismatch", status: remoteHostVersionMismatch, want: picker.RemoteVersionMismatch}, | 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 | for _, test := range tests { | 291 | for _, test := range tests { | ||
| 261 | t.Run(test.name, func(t *testing.T) { | 292 | t.Run(test.name, func(t *testing.T) { | ||
| 262 | - | d := newRemotePickerDaemon(nil) | 293 | + | d := newRemotePickerDaemon() |
| 263 | - | d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{ | 294 | + | seedRemoteDirectory(t, d, test.host) |
| 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() | |||
| 269 | 295 | ||||
| 270 | views, _ := d.pickerViews(nil, nil) | 296 | views, _ := d.pickerViews(nil, nil) | ||
| 271 | 297 | ||||
| 272 | require.Len(t, views, 1) | 298 | require.Len(t, views, 1) | ||
| 273 | require.Equal(t, test.want, views[0].RemoteAvailability) | 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 | func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) { | 309 | func TestRemotePickerStaleDetailUsesLastSuccessfulFetch(t *testing.T) { | ||
| 279 | - | d := newRemotePickerDaemon(nil) | |||
| 280 | fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC) | 310 | fetchedAt := time.Date(2025, time.January, 2, 3, 4, 5, 0, time.UTC) | ||
| 281 | - | failedAt := fetchedAt.Add(6 * time.Hour) | 311 | + | session := catalogue.RemoteCatalogSession{Name: "work", State: "up"} |
| 282 | - | d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{ | 312 | + | d := newRemotePickerDaemon() |
| 283 | - | Host: "arch", FetchedAt: fetchedAt, Sessions: []catalogue.RemoteCatalogSession{{Name: "work", State: "up"}}, | 313 | + | seedRemoteDirectory(t, d, ports.RemoteHostSnapshot{ |
| 284 | - | }}) | 314 | + | Endpoint: "arch", Availability: domain.RemoteAvailabilityUnreachable, |
| 285 | - | d.remoteCatalog.mu.Lock() | 315 | + | LastSuccess: fetchedAt, InventoryKnown: true, |
| 286 | - | d.remoteCatalog.status["arch"] = remoteHostUnreachable | 316 | + | Sessions: []catalogue.RemoteCatalogSession{session}, |
| 287 | - | d.remoteCatalog.failure["arch"] = failedAt | 317 | + | }) |
| 288 | - | d.remoteCatalog.mu.Unlock() | |||
| 289 | 318 | ||||
| 290 | views, _ := d.pickerViews(nil, nil) | 319 | views, _ := d.pickerViews(nil, nil) | ||
| 291 | 320 | ||||
| 292 | require.Len(t, views, 1) | 321 | require.Len(t, views, 1) | ||
| 293 | require.Equal(t, "stale since "+fetchedAt.Format(time.RFC3339), views[0].RemoteDetail) | 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 | for _, test := range []struct { | 326 | for _, test := range []struct { | ||
| 299 | name string | 327 | name string | ||
| 300 | - | status remoteHostStatus | 328 | + | host ports.RemoteHostSnapshot |
| 301 | want picker.RemoteAvailability | 329 | want picker.RemoteAvailability | ||
| 302 | wantDetail string | 330 | wantDetail string | ||
| 303 | }{ | 331 | }{ | ||
| 304 | - | {name: "unreachable", status: remoteHostUnreachable, want: picker.RemoteStale, wantDetail: "unreachable"}, | 332 | + | { |
| 305 | - | {name: "version mismatch", status: remoteHostVersionMismatch, want: picker.RemoteVersionMismatch, wantDetail: "version mismatch"}, | 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 | t.Run(test.name, func(t *testing.T) { | 345 | t.Run(test.name, func(t *testing.T) { | ||
| 308 | - | d := newRemotePickerDaemon(nil) | 346 | + | d := newRemotePickerDaemon() |
| 309 | - | d.remoteCatalog.mu.Lock() | 347 | + | seedRemoteDirectory(t, d, test.host) |
| 310 | - | d.remoteCatalog.status["arch"] = test.status | |||
| 311 | - | d.remoteCatalog.failure["arch"] = time.Unix(50, 0) | |||
| 312 | - | d.remoteCatalog.mu.Unlock() | |||
| 313 | 348 | ||||
| 314 | views, _ := d.pickerViews(nil, nil) | 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 { | 362 | + | // TestRemotePickerStaleAndCheckingInventoryRemainsAttemptable is the |
| 328 | - | mu sync.Mutex | 363 | + | // presentation half of the P1.1 regression: age and in-flight observation |
| 329 | - | hosts []string | 364 | + | // render while rows stay attemptable; only broken/invalid/incompatible |
| 330 | - | } | 365 | + | // states gate activation. |
| 331 | - | 366 | + | func TestRemotePickerStaleAndCheckingInventoryRemainsAttemptable(t *testing.T) { | |
| 332 | - | func (s *remoteRefreshHostStore) Hosts() ([]string, []string, error) { | 367 | + | lifecycle := remoteLifecycleForTest() |
| 333 | - | s.mu.Lock() | 368 | + | session := catalogue.RemoteCatalogSession{ |
| 334 | - | defer s.mu.Unlock() | 369 | + | LifecycleID: lifecycle, Name: "work", State: "up", |
| 335 | - | return append([]string(nil), s.hosts...), nil, nil | 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) { | 397 | + | // TestRemotePickerOverlayCausesNoDirectoryIO proves presentation performs no |
| 339 | - | s.mu.Lock() | 398 | + | // registry, cache, or observation I/O: opening, rebuilding, and closing the |
| 340 | - | s.hosts = append([]string(nil), hosts...) | 399 | + | // picker only re-reads the latest snapshot. |
| 341 | - | s.mu.Unlock() | 400 | + | func TestRemotePickerOverlayCausesNoDirectoryIO(t *testing.T) { |
| 342 | - | } | 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 } | 408 | + | model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) |
| 345 | - | func (*remoteRefreshHostStore) RemovePinned(string) error { return nil } | 409 | + | d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{}) |
| 346 | - | func (*remoteRefreshHostStore) Remember(string) error { return nil } | 410 | + | d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1}) |
| 347 | - | func (*remoteRefreshHostStore) Forget(string) error { return nil } | 411 | + | d.closePicker(ac) |
| 348 | - | func (*remoteRefreshHostStore) Remove(string) (bool, error) { | |||
| 349 | - | return false, nil | |||
| 350 | - | } | |||
| 351 | 412 | ||||
| 352 | - | type remoteRefreshRequest struct { | 413 | + | require.Zero(t, directory.reconciles, "overlay lifecycle must never request reconciliation") |
| 353 | - | ctx context.Context | 414 | + | require.GreaterOrEqual(t, directory.snapshots, 1, "presentation renders from snapshots") |
| 354 | - | host string | |||
| 355 | - | result chan remoteRefreshResult | |||
| 356 | } | 415 | } | ||
| 357 | 416 | ||||
| 358 | - | type remoteRefreshResult struct { | 417 | + | type countingRemoteDirectory struct { |
| 359 | - | catalog catalogue.RemoteCatalog | 418 | + | mu sync.Mutex |
| 360 | - | err error | 419 | + | snapshot ports.RemoteDirectorySnapshot |
| 420 | + | snapshots int | |||
| 421 | + | reconciles int | |||
| 422 | + | registry int | |||
| 361 | } | 423 | } | ||
| 362 | 424 | ||||
| 363 | - | type channelRemoteCatalog struct { | 425 | + | func (s *countingRemoteDirectory) Snapshot() ports.RemoteDirectorySnapshot { |
| 364 | - | d *Daemon | 426 | + | s.mu.Lock() |
| 365 | - | requests chan remoteRefreshRequest | 427 | + | defer s.mu.Unlock() |
| 366 | - | probeMu sync.Mutex | 428 | + | s.snapshots++ |
| 367 | - | probes bool | 429 | + | return s.snapshot.Clone() |
| 368 | - | violations []string | |||
| 369 | } | 430 | } | ||
| 370 | 431 | ||||
| 371 | - | func (c *channelRemoteCatalog) List(ctx context.Context, host string) (catalogue.RemoteCatalog, error) { | 432 | + | func (s *countingRemoteDirectory) Subscribe() ports.RemoteDirectorySubscription { |
| 372 | - | c.probeMu.Lock() | 433 | + | return &stubDirectorySubscription{changed: make(chan struct{}, 1)} |
| 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 | - | } | |||
| 392 | } | 434 | } | ||
| 393 | 435 | ||||
| 394 | - | type recordingRemoteCache struct { | 436 | + | func (s *countingRemoteDirectory) RequestReconcile(endpoint string) { |
| 395 | - | d *Daemon | 437 | + | s.mu.Lock() |
| 396 | - | stores chan []catalogue.RemoteCatalogCacheEntry | 438 | + | defer s.mu.Unlock() |
| 397 | - | probeMu sync.Mutex | 439 | + | s.reconciles++ |
| 398 | - | probes bool | |||
| 399 | - | violations []string | |||
| 400 | } | 440 | } | ||
| 401 | 441 | ||||
| 402 | - | func (*recordingRemoteCache) Load() ([]catalogue.RemoteCatalogCacheEntry, error) { return nil, nil } | 442 | + | func (s *countingRemoteDirectory) RegistryChanged() { |
| 403 | - | 443 | + | s.mu.Lock() | |
| 404 | - | func (c *recordingRemoteCache) Store(entries []catalogue.RemoteCatalogCacheEntry) error { | 444 | + | defer s.mu.Unlock() |
| 405 | - | c.probeMu.Lock() | 445 | + | s.registry++ |
| 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 | |||
| 423 | } | 446 | } | ||
| 424 | 447 | ||||
| 425 | type fixedRemoteRefreshClock struct{ now time.Time } | 448 | type fixedRemoteRefreshClock struct{ now time.Time } | ||
| @@ -444,20 +467,6 @@ func (c *remotePickerClock) Advance(d time.Duration) { | |||||
| 444 | c.mu.Unlock() | 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 | func remoteCatalogForTest(sessions ...catalogue.RemoteCatalogSession) catalogue.RemoteCatalog { | 470 | func remoteCatalogForTest(sessions ...catalogue.RemoteCatalogSession) catalogue.RemoteCatalog { | ||
| 462 | return catalogue.RemoteCatalog{ | 471 | return catalogue.RemoteCatalog{ | ||
| 463 | ProtocolVersion: protocol.Version, | 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 | t.Helper() | 479 | t.Helper() | ||
| 471 | - | catalog := &channelRemoteCatalog{requests: make(chan remoteRefreshRequest, 8)} | 480 | + | seedRemoteDirectory(t, d, reachableDirectoryHost(endpoint, fetchedAt, sessions...)) |
| 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() | |||
| 676 | } | 481 | } | ||
| 677 | 482 | ||||
| 678 | func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) { | 483 | func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) { | ||
| @@ -681,13 +486,8 @@ func TestRemotePickerHandoffSendsTargetAndLeavesNoShadowSession(t *testing.T) { | |||||
| 681 | LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, | 486 | LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, | ||
| 682 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}}, | 487 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}}, | ||
| 683 | } | 488 | } | ||
| 684 | - | d := newRemotePickerDaemon(nil) | 489 | + | d := newRemotePickerDaemon() |
| 685 | - | d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{ | 490 | + | seedPickerDirectoryHost(t, d, "arch", time.Unix(10, 0), remoteSession) |
| 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() | |||
| 691 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | 491 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | ||
| 692 | token := sess.captureAttachmentCapability(ac, ac.transport()) | 492 | token := sess.captureAttachmentCapability(ac, ac.transport()) | ||
| 693 | effect, admitted := ac.beginAttachmentEffect(token) | 493 | effect, admitted := ac.beginAttachmentEffect(token) | ||
| @@ -720,7 +520,8 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) { | |||||
| 720 | }, | 520 | }, | ||
| 721 | } | 521 | } | ||
| 722 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | 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 | model := picker.New([]picker.SessionView{ | 525 | model := picker.New([]picker.SessionView{ | ||
| 725 | {ID: "local", Name: "local", Tabs: []picker.TabEntry{{TabID: "local-tab", Name: "local"}}}, | 526 | {ID: "local", Name: "local", Tabs: []picker.TabEntry{{TabID: "local-tab", Name: "local"}}}, | ||
| 726 | remoteView, | 527 | remoteView, | ||
| @@ -738,11 +539,8 @@ func TestRemotePickerSelectsStoppedRemoteTabAndRestoresIt(t *testing.T) { | |||||
| 738 | require.True(t, selected.RemoteTarget.Stopped) | 539 | require.True(t, selected.RemoteTarget.Stopped) | ||
| 739 | require.Equal(t, domain.NewStableTabSelector("tab-b"), selected.RemoteTarget.StoppedTab) | 540 | require.Equal(t, domain.NewStableTabSelector("tab-b"), selected.RemoteTarget.StoppedTab) | ||
| 740 | 541 | ||||
| 741 | - | local := newRemotePickerDaemon(nil) | 542 | + | local := newRemotePickerDaemon() |
| 742 | - | local.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{Host: "arch", FetchedAt: time.Unix(10, 0), Sessions: []catalogue.RemoteCatalogSession{remoteSession}}}) | 543 | + | seedPickerDirectoryHost(t, local, "arch", time.Unix(10, 0), remoteSession) |
| 743 | - | local.remoteCatalog.mu.Lock() | |||
| 744 | - | local.remoteCatalog.status["arch"] = remoteHostFresh | |||
| 745 | - | local.remoteCatalog.mu.Unlock() | |||
| 746 | localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local") | 544 | localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local") | ||
| 747 | token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport()) | 545 | token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport()) | ||
| 748 | effect, admitted := localAttachment.beginAttachmentEffect(token) | 546 | effect, admitted := localAttachment.beginAttachmentEffect(token) | ||
| @@ -787,7 +585,8 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing | |||||
| 787 | Tabs: []catalogue.RemoteCatalogTab{}, | 585 | Tabs: []catalogue.RemoteCatalogTab{}, | ||
| 788 | } | 586 | } | ||
| 789 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | 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 | require.Equal(t, picker.RemoteRestart, remoteView.RemoteActivation) | 590 | require.Equal(t, picker.RemoteRestart, remoteView.RemoteActivation) | ||
| 792 | 591 | ||||
| 793 | model := picker.New([]picker.SessionView{ | 592 | model := picker.New([]picker.SessionView{ | ||
| @@ -807,13 +606,8 @@ func TestRemotePickerResurrectsStoppedRemoteSessionWithoutTabMetadata(t *testing | |||||
| 807 | require.True(t, selected.RemoteTarget.Stopped) | 606 | require.True(t, selected.RemoteTarget.Stopped) | ||
| 808 | require.Equal(t, domain.TabSelector{}, selected.RemoteTarget.StoppedTab) | 607 | require.Equal(t, domain.TabSelector{}, selected.RemoteTarget.StoppedTab) | ||
| 809 | 608 | ||||
| 810 | - | local := newRemotePickerDaemon(nil) | 609 | + | local := newRemotePickerDaemon() |
| 811 | - | local.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{ | 610 | + | seedPickerDirectoryHost(t, local, "arch", time.Unix(10, 0), remoteSession) |
| 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() | |||
| 817 | localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local") | 611 | localSession, localAttachment, sends := addRemoteRefreshPickerOwner(t, local, "local") | ||
| 818 | token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport()) | 612 | token := localSession.captureAttachmentCapability(localAttachment, localAttachment.transport()) | ||
| 819 | effect, admitted := localAttachment.beginAttachmentEffect(token) | 613 | effect, admitted := localAttachment.beginAttachmentEffect(token) | ||
| @@ -853,7 +647,7 @@ func TestNavigationActionHandoffSendsBoundedAction(t *testing.T) { | |||||
| 853 | 647 | ||||
| 854 | for _, tt := range tests { | 648 | for _, tt := range tests { | ||
| 855 | t.Run(tt.name, func(t *testing.T) { | 649 | t.Run(tt.name, func(t *testing.T) { | ||
| 856 | - | d := newRemotePickerDaemon(nil) | 650 | + | d := newRemotePickerDaemon() |
| 857 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | 651 | sess, ac, sends := addRemoteRefreshPickerOwner(t, d, "local") | ||
| 858 | if tt.action == protocol.NavigationOpenHomePicker { | 652 | if tt.action == protocol.NavigationOpenHomePicker { | ||
| 859 | ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker | 653 | ac.navigationCapabilities = protocol.NavigationCapabilityHomePicker | ||
| @@ -883,13 +677,8 @@ func TestRemotePickerHandoffSendFailureKeepsPickerOpen(t *testing.T) { | |||||
| 883 | LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, | 677 | LifecycleID: lifecycle, Name: "work", State: catalogue.RemoteCatalogSessionUp, | ||
| 884 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}}, | 678 | Tabs: []catalogue.RemoteCatalogTab{{ID: "tab-1"}}, | ||
| 885 | } | 679 | } | ||
| 886 | - | d := newRemotePickerDaemon(nil) | 680 | + | d := newRemotePickerDaemon() |
| 887 | - | d.remoteCatalog.replaceCache([]catalogue.RemoteCatalogCacheEntry{{ | 681 | + | seedPickerDirectoryHost(t, d, "arch", time.Unix(10, 0), remoteSession) |
| 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() | |||
| 893 | cause := errors.New("remote attach send failed") | 682 | cause := errors.New("remote attach send failed") | ||
| 894 | tr := portsmocks.NewMockServerConnection(t) | 683 | tr := portsmocks.NewMockServerConnection(t) | ||
| 895 | tr.EXPECT().SendServer(mock.Anything).Return(cause) | 684 | tr.EXPECT().SendServer(mock.Anything).Return(cause) | ||
| @@ -943,49 +732,6 @@ func addRemoteRefreshPickerOwner(t *testing.T, d *Daemon, id domain.SessionID, t | |||||
| 943 | return sess, ac, sends | 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 | func TestRemotePickerTeardownLifecycle(t *testing.T) { | 735 | func TestRemotePickerTeardownLifecycle(t *testing.T) { | ||
| 990 | for _, test := range []struct { | 736 | for _, test := range []struct { | ||
| 991 | name string | 737 | name string | ||
| @@ -998,7 +744,7 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) { | |||||
| 998 | {name: "send error removes non-resumable attachment", teardown: func(d *Daemon, sess *session, ac *attachedClient) { d.detachOnSendError(sess, ac, ac.transport()) }}, | 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 | t.Run(test.name, func(t *testing.T) { | 746 | t.Run(test.name, func(t *testing.T) { | ||
| 1001 | - | d := newRemotePickerDaemon(nil) | 747 | + | d := newRemotePickerDaemon() |
| 1002 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | 748 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | ||
| 1003 | ac.resumeCapable = test.resumeCapable | 749 | ac.resumeCapable = test.resumeCapable | ||
| 1004 | model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | 750 | model := d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}) | ||
| @@ -1009,19 +755,14 @@ func TestRemotePickerTeardownLifecycle(t *testing.T) { | |||||
| 1009 | 755 | ||||
| 1010 | test.teardown(d, sess, ac) | 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 | ac.overlays.pickerMu.Lock() | 758 | ac.overlays.pickerMu.Lock() | ||
| 1016 | current, currentGeneration := ac.overlays.picker, ac.overlays.pickerGeneration | 759 | current, currentGeneration := ac.overlays.picker, ac.overlays.pickerGeneration | ||
| 1017 | ac.overlays.pickerMu.Unlock() | 760 | ac.overlays.pickerMu.Unlock() | ||
| 1018 | if !test.resumeCapable { | 761 | if !test.resumeCapable { | ||
| 1019 | - | require.False(t, registered) | |||
| 1020 | require.Nil(t, current) | 762 | require.Nil(t, current) | ||
| 1021 | return | 763 | return | ||
| 1022 | } | 764 | } | ||
| 1023 | require.True(t, ac.parked) | 765 | require.True(t, ac.parked) | ||
| 1024 | - | require.True(t, registered) | |||
| 1025 | require.Same(t, model, current) | 766 | require.Same(t, model, current) | ||
| 1026 | require.Equal(t, generation, currentGeneration) | 767 | require.Equal(t, generation, currentGeneration) | ||
| 1027 | d.mu.Lock() | 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) { | 777 | + | // TestPickerShowsCheckingRowUntilDirectoryInitializes proves the picker |
| 1037 | - | clock := &signalClock{timers: make(chan *signalTimer, 1)} | 778 | + | // distinguishes "still checking remotes" from "no remotes": an |
| 1038 | - | hosts := &remoteRefreshHostStore{hosts: []string{"arch"}} | 779 | + | // uninitialized directory publishes a non-actionable placeholder row, and |
| 1039 | - | d, catalog, _ := newRemoteRefreshDaemon(t, hosts, time.Unix(475, 0)) | 780 | + | // the first publication (even empty) retires it. |
| 1040 | - | d.clock = clock | 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 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | 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)) | 788 | + | findChecking := func(views []picker.SessionView) bool { |
| 1050 | - | timer := receiveRemotePicker(t, clock.timers, "park expiry timer") | 789 | + | for _, view := range views { |
| 1051 | - | timer.ch <- clock.Now() | 790 | + | if view.ID == domain.SessionID("remote:checking") { |
| 1052 | - | receiveRemotePickerClose(t, request.ctx.Done(), "catalog request cancellation") | 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 | ac.overlays.pickerMu.Lock() | 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 | ac.overlays.pickerMu.Unlock() | 832 | ac.overlays.pickerMu.Unlock() | ||
| 1057 | - | ac.overlays.paletteMu.Lock() | 833 | + | require.True(t, ok && target.RemoteTarget != nil, "picker must offer the seeded remote row") |
| 1058 | - | require.Nil(t, ac.overlays.palette) | 834 | + | |
| 1059 | - | ac.overlays.paletteMu.Unlock() | 835 | + | // Simulate a blocked remote preview fetch. |
| 1060 | - | d.remoteCatalog.mu.Lock() | 836 | + | cancelled := make(chan struct{}) |
| 1061 | - | require.NotContains(t, d.remoteCatalog.consumers, ac) | 837 | + | _, cancel := context.WithCancel(context.Background()) |
| 1062 | - | d.remoteCatalog.mu.Unlock() | 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 | func TestParkedPickerRetirementClosesCapturedGenerationOnly(t *testing.T) { | 871 | func TestParkedPickerRetirementClosesCapturedGenerationOnly(t *testing.T) { | ||
| 1066 | - | d := newRemotePickerDaemon(nil) | 872 | + | d := newRemotePickerDaemon() |
| 1067 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | 873 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | ||
| 1068 | ac.resumeCapable = true | 874 | ac.resumeCapable = true | ||
| 1069 | d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | 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 | func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) { | 897 | func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) { | ||
| 1092 | - | d := newRemotePickerDaemon(nil) | 898 | + | d := newRemotePickerDaemon() |
| 1093 | firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first") | 899 | firstSession, first, _ := addRemoteRefreshPickerOwner(t, d, "first") | ||
| 1094 | secondSession, second, _ := addRemoteRefreshPickerOwner(t, d, "second") | 900 | secondSession, second, _ := addRemoteRefreshPickerOwner(t, d, "second") | ||
| 1095 | first.resumeCapable, second.resumeCapable = true, true | 901 | first.resumeCapable, second.resumeCapable = true, true | ||
| @@ -1109,7 +915,7 @@ func TestParkedPickerTokenReplacementRetiresPreviousGeneration(t *testing.T) { | |||||
| 1109 | } | 915 | } | ||
| 1110 | 916 | ||||
| 1111 | func TestParkedPickerResumePreservesGeneration(t *testing.T) { | 917 | func TestParkedPickerResumePreservesGeneration(t *testing.T) { | ||
| 1112 | - | d := newRemotePickerDaemon(nil) | 918 | + | d := newRemotePickerDaemon() |
| 1113 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | 919 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | ||
| 1114 | ac.resumeCapable = true | 920 | ac.resumeCapable = true | ||
| 1115 | ac.clientID = [16]byte{1, 2, 3, 4} | 921 | ac.clientID = [16]byte{1, 2, 3, 4} | ||
| @@ -1132,7 +938,7 @@ func TestParkedPickerResumePreservesGeneration(t *testing.T) { | |||||
| 1132 | } | 938 | } | ||
| 1133 | 939 | ||||
| 1134 | func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) { | 940 | func TestStaleParkedPickerExpiryPreservesNewGeneration(t *testing.T) { | ||
| 1135 | - | d := newRemotePickerDaemon(nil) | 941 | + | d := newRemotePickerDaemon() |
| 1136 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | 942 | sess, ac, _ := addRemoteRefreshPickerOwner(t, d, "owner") | ||
| 1137 | ac.resumeCapable = true | 943 | ac.resumeCapable = true | ||
| 1138 | d.publishPicker(sess, ac, d.newPickerModel(sess, nil, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{}), pickerNavigate, moveSourceLocator{}) | 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 | require.Same(t, newer, ac.overlays.picker) | 956 | require.Same(t, newer, ac.overlays.picker) | ||
| 1151 | ac.overlays.pickerMu.Unlock() | 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 | remote := session.RemoteKey != nil || session.RemoteTarget != nil || session.RemoteHost != "" | 453 | remote := session.RemoteKey != nil || session.RemoteTarget != nil || session.RemoteHost != "" | ||
| 454 | if remote { | 454 | if remote { | ||
| 455 | switch session.RemoteReason { | 455 | switch session.RemoteReason { | ||
| 456 | - | case "host_unreachable": | 456 | + | case domain.RemoteReasonHostUnreachable: |
| 457 | return rowStatusDown | 457 | return rowStatusDown | ||
| 458 | - | case "version_mismatch": | 458 | + | case domain.RemoteReasonVersionMismatch: |
| 459 | return rowStatusVersion | 459 | return rowStatusVersion | ||
| 460 | - | case "refreshing", "catalog_stale": | 460 | + | case domain.RemoteReasonRefreshing, domain.RemoteReasonCatalogStale: |
| 461 | return rowStatusStale | 461 | return rowStatusStale | ||
| 462 | - | case "malformed", "session_broken", "identity_changed": | 462 | + | case domain.RemoteReasonMalformed, domain.RemoteReasonSessionBroken, domain.RemoteReasonIdentityChanged, domain.RemoteReasonAuthFailure: |
| 463 | return rowStatusError | 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 | lifecycle := domain.SessionLifecycleID{1} | 842 | lifecycle := domain.SessionLifecycleID{1} | ||
| 843 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | 843 | key := domain.RemoteSessionKey{Host: "arch", Name: "work", LifecycleID: lifecycle, DisplayOrigin: "arch"} | ||
| 844 | target := domain.RemoteSessionTarget{Endpoint: "arch", DisplayOrigin: "arch", LifecycleID: lifecycle, SessionName: "work", Stopped: true} | 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 | frame := m.Render(domain.Size{Cols: 40, Rows: 3}, Preview{}) | 847 | frame := m.Render(domain.Size{Cols: 40, Rows: 3}, Preview{}) | ||
| 848 | require.Contains(t, rowText(frame.Row(0)), "[stale]") | 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 | # syntax=docker/dockerfile:1 | 1 | # syntax=docker/dockerfile:1 | ||
| 2 | - | FROM golang:1.26 AS build | 2 | + | FROM golang:1.27 AS build |
| 3 | WORKDIR /src | 3 | WORKDIR /src | ||
| 4 | COPY go.mod go.sum ./ | 4 | COPY go.mod go.sum ./ | ||
| 5 | RUN go mod download | 5 | RUN go mod download | ||