feat(client): project the picker from broker snapshots
Add the client-owned picker: rows come from broker snapshots instead of a serving daemon's catalogue, and a selection resolves to an exact broker stream request. Offline and unactivated; no production composition uses it yet. - picker_catalogue.go projects ports.BrokerSnapshot into stable host/session rows, merging observations monotonically within an epoch and resyncing wholesale on a new epoch, with keys derived only from daemon and session identity so a rename keeps its row and a replacement changes it. - Classification follows availability before compatibility: an unobserved or unreachable daemon carries no protocol version, so it reports as unavailable/refreshing and refuses as unavailable rather than as a version mismatch. Auth-failed and malformed hosts keep their own reasons. - Selection revalidates the exact identity against the latest snapshot and refuses with a typed bounded code when it is gone, replaced, stale, incompatible, or unavailable; a snapshot failing its own contract is rejected and the previous projection stays current. - picker_controller.go renders the latest snapshot immediately, folds into the supervisor's single terminal input lifetime (no second reader, no path to a PTY or session), surfaces bounded notices, and resolves the row the user committed rather than whichever row is displayed when the request is built.
- commit
- b6528f07704a098d00546f1e130c2e4e7b030ebd
- author
- brice · · 2026-09-19 09:52 UTC
- parent
- a734bf5
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
6 files · +3069 -29
A internal/usecase/client/picker_catalogue.go +936 -0
| @@ -0,0 +1,936 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "crypto/sha256" | |
| 5 | + | "encoding/base64" | |
| 6 | + | "errors" | |
| 7 | + | "fmt" | |
| 8 | + | "strings" | |
| 9 | + | "sync" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "github.com/bnema/vev/internal/domain" | |
| 13 | + | "github.com/bnema/vev/internal/ports" | |
| 14 | + | "github.com/bnema/vev/internal/protocol" | |
| 15 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Client-owned picker catalogue (Plan 001 P5.2b, offline and unactivated). | |
| 19 | + | // | |
| 20 | + | // The autonomous supervisor owns the broker connection and the single terminal | |
| 21 | + | // input lifetime; this file owns the catalogue the picker presents, projected | |
| 22 | + | // from the broker's own observations rather than from a serving-daemon | |
| 23 | + | // catalogue. It is the P5.2b replacement source for the daemon-served | |
| 24 | + | // protocol.PickerSnapshot: rows are derived from ports.BrokerSnapshot, the | |
| 25 | + | // opaque keys stay client-local, and a selection resolves to an exact | |
| 26 | + | // ports.BrokerOpenStreamRequest instead of a daemon-side key handoff. | |
| 27 | + | // | |
| 28 | + | // The projection preserves configured authority and observed state separately, | |
| 29 | + | // exactly as ports.BrokerDaemonObservation does: | |
| 30 | + | // | |
| 31 | + | // - daemon identity is the local flag or the remote endpoint plus the | |
| 32 | + | // registration incarnation; a re-registered endpoint is an identity | |
| 33 | + | // replacement and its rows never survive. | |
| 34 | + | // - session identity is the catalogue lifecycle ID; a same-name replacement | |
| 35 | + | // resolves as "replaced", never by name. | |
| 36 | + | // - freshness is the LastSuccess window against the injected clock. | |
| 37 | + | // - compatibility is the observed protocol version against the policy's | |
| 38 | + | // required version. | |
| 39 | + | // - availability is the observed RemoteAvailability. | |
| 40 | + | // | |
| 41 | + | // Updates are monotonic: a publication that does not supersede the applied | |
| 42 | + | // epoch/revision is ignored, and a per-daemon observation that regresses | |
| 43 | + | // (older LastSuccess and no newer failure episode, same incarnation) keeps the | |
| 44 | + | // newer observed state while still refreshing configured authority. Row order | |
| 45 | + | // is stable: hosts keep their first-seen position and unrelated updates never | |
| 46 | + | // reshuffle them. Nothing here attaches to a daemon, starts one, or writes a | |
| 47 | + | // session. | |
| 48 | + | ||
| 49 | + | const ( | |
| 50 | + | // pickerCatalogueSourceID names the single client-owned picker source. | |
| 51 | + | pickerCatalogueSourceID = "broker" | |
| 52 | + | // pickerCatalogueDefaultFreshness bounds how old a LastSuccess may be | |
| 53 | + | // before a row is displayed as stale and refused at resolution. | |
| 54 | + | pickerCatalogueDefaultFreshness = 30 * time.Second | |
| 55 | + | // pickerCatalogueMaxRetiredRefs bounds the retired-key fence used to | |
| 56 | + | // distinguish "gone" from "replaced" after a row leaves the projection. | |
| 57 | + | pickerCatalogueMaxRetiredRefs = 128 | |
| 58 | + | ) | |
| 59 | + | ||
| 60 | + | // pickerCatalogueErrorCode is the closed refusal taxonomy for a client-owned | |
| 61 | + | // picker selection. The zero value is never produced: a refusal always names | |
| 62 | + | // why the exact selection cannot be resolved. | |
| 63 | + | type pickerCatalogueErrorCode uint8 | |
| 64 | + | ||
| 65 | + | const ( | |
| 66 | + | pickerCatalogueUnknown pickerCatalogueErrorCode = iota + 1 | |
| 67 | + | pickerCatalogueGone | |
| 68 | + | pickerCatalogueReplaced | |
| 69 | + | pickerCatalogueStale | |
| 70 | + | pickerCatalogueIncompatible | |
| 71 | + | pickerCatalogueUnavailable | |
| 72 | + | pickerCatalogueEpochStale | |
| 73 | + | pickerCatalogueInvalidName | |
| 74 | + | pickerCatalogueNoSelection | |
| 75 | + | ) | |
| 76 | + | ||
| 77 | + | func (c pickerCatalogueErrorCode) String() string { | |
| 78 | + | switch c { | |
| 79 | + | case pickerCatalogueUnknown: | |
| 80 | + | return "unknown" | |
| 81 | + | case pickerCatalogueGone: | |
| 82 | + | return "gone" | |
| 83 | + | case pickerCatalogueReplaced: | |
| 84 | + | return "replaced" | |
| 85 | + | case pickerCatalogueStale: | |
| 86 | + | return "stale" | |
| 87 | + | case pickerCatalogueIncompatible: | |
| 88 | + | return "incompatible" | |
| 89 | + | case pickerCatalogueUnavailable: | |
| 90 | + | return "unavailable" | |
| 91 | + | case pickerCatalogueEpochStale: | |
| 92 | + | return "stale_epoch" | |
| 93 | + | case pickerCatalogueInvalidName: | |
| 94 | + | return "invalid_name" | |
| 95 | + | case pickerCatalogueNoSelection: | |
| 96 | + | return "no_selection" | |
| 97 | + | default: | |
| 98 | + | return "unknown" | |
| 99 | + | } | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | // pickerCatalogueError is a typed, bounded refusal. It never carries a raw | |
| 103 | + | // endpoint, credential, or adapter diagnostic as display text. | |
| 104 | + | type pickerCatalogueError struct { | |
| 105 | + | Code pickerCatalogueErrorCode | |
| 106 | + | Text string | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (e pickerCatalogueError) Error() string { | |
| 110 | + | if e.Text == "" { | |
| 111 | + | return "vev: picker selection " + e.Code.String() | |
| 112 | + | } | |
| 113 | + | return "vev: picker selection " + e.Code.String() + ": " + e.Text | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | // pickerCatalogueErrorIs reports whether err is a typed catalogue refusal with | |
| 117 | + | // the supplied code, so callers classify without matching message text. | |
| 118 | + | func pickerCatalogueErrorIs(err error, code pickerCatalogueErrorCode) bool { | |
| 119 | + | var typed pickerCatalogueError | |
| 120 | + | return errors.As(err, &typed) && typed.Code == code | |
| 121 | + | } | |
| 122 | + | ||
| 123 | + | // pickerSelectionKind names one closed admission variant a resolved selection | |
| 124 | + | // can carry. | |
| 125 | + | type pickerSelectionKind uint8 | |
| 126 | + | ||
| 127 | + | const ( | |
| 128 | + | // pickerSelectionExact attaches to one exact session lifecycle. | |
| 129 | + | pickerSelectionExact pickerSelectionKind = iota + 1 | |
| 130 | + | // pickerSelectionCreateNamed creates one named session. | |
| 131 | + | pickerSelectionCreateNamed | |
| 132 | + | // pickerSelectionCreateEphemeral creates one ephemeral session. | |
| 133 | + | pickerSelectionCreateEphemeral | |
| 134 | + | ) | |
| 135 | + | ||
| 136 | + | // pickerSelectionRef is the client's exact selection identity, captured when a | |
| 137 | + | // row was projected. Resolution revalidates it against the latest snapshot; it | |
| 138 | + | // is never reconstructed from a display label. | |
| 139 | + | type pickerSelectionRef struct { | |
| 140 | + | kind pickerSelectionKind | |
| 141 | + | epoch ports.BrokerEpoch | |
| 142 | + | local bool | |
| 143 | + | endpoint string | |
| 144 | + | registration domain.RemoteRegistration | |
| 145 | + | lifecycle domain.SessionLifecycleID | |
| 146 | + | name string | |
| 147 | + | createName string | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // pickerResolveBase carries the live broker connection identity a resolved | |
| 151 | + | // request must be fenced to. The catalogue supplies everything else. | |
| 152 | + | type pickerResolveBase struct { | |
| 153 | + | Connection ports.BrokerConnectionID | |
| 154 | + | Stream ports.BrokerStreamID | |
| 155 | + | Env []string | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | // pickerHostState is one projected daemon and its newest accepted | |
| 159 | + | // observation. | |
| 160 | + | type pickerHostState struct { | |
| 161 | + | observation ports.BrokerDaemonObservation | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // pickerCatalogueConfig supplies the fresh-window clock. Clock is optional and | |
| 165 | + | // falls back to the system clock; Freshness defaults to | |
| 166 | + | // pickerCatalogueDefaultFreshness. | |
| 167 | + | type pickerCatalogueConfig struct { | |
| 168 | + | Clock ports.Clock | |
| 169 | + | Freshness time.Duration | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // pickerCatalogue is the client-owned, monotonic projection of broker daemon | |
| 173 | + | // observations into ordered picker rows. It is safe for concurrent use: the | |
| 174 | + | // supervisor applies publications from its run path while the picker loop | |
| 175 | + | // resolves selections. | |
| 176 | + | type pickerCatalogue struct { | |
| 177 | + | clock ports.Clock | |
| 178 | + | freshness time.Duration | |
| 179 | + | ||
| 180 | + | mu sync.Mutex | |
| 181 | + | snapshot ports.BrokerSnapshot | |
| 182 | + | hosts map[string]*pickerHostState | |
| 183 | + | order []string | |
| 184 | + | refs map[string]pickerSelectionRef | |
| 185 | + | retired map[string]pickerSelectionRef | |
| 186 | + | retiredOrder []string | |
| 187 | + | lines []protocol.PickerLine | |
| 188 | + | cursor protocol.PickerCursor | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | // newPickerCatalogue builds an empty projection. | |
| 192 | + | func newPickerCatalogue(cfg pickerCatalogueConfig) *pickerCatalogue { | |
| 193 | + | clock := cfg.Clock | |
| 194 | + | if supervisorNil(clock) { | |
| 195 | + | clock = systemClock{} | |
| 196 | + | } | |
| 197 | + | freshness := cfg.Freshness | |
| 198 | + | if freshness <= 0 { | |
| 199 | + | freshness = pickerCatalogueDefaultFreshness | |
| 200 | + | } | |
| 201 | + | return &pickerCatalogue{ | |
| 202 | + | clock: clock, | |
| 203 | + | freshness: freshness, | |
| 204 | + | hosts: make(map[string]*pickerHostState), | |
| 205 | + | refs: make(map[string]pickerSelectionRef), | |
| 206 | + | retired: make(map[string]pickerSelectionRef), | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // Apply folds one broker publication into the projection. It reports whether | |
| 211 | + | // the projection changed. The publication is the projection's trust boundary: | |
| 212 | + | // a snapshot that fails its own contract is rejected outright and the previous | |
| 213 | + | // projection is kept. A valid publication that does not supersede the applied | |
| 214 | + | // epoch/revision is also ignored, so a slow or out-of-order snapshot can never | |
| 215 | + | // regress a row. | |
| 216 | + | func (c *pickerCatalogue) Apply(snapshot ports.BrokerSnapshot) bool { | |
| 217 | + | if c == nil { | |
| 218 | + | return false | |
| 219 | + | } | |
| 220 | + | c.mu.Lock() | |
| 221 | + | defer c.mu.Unlock() | |
| 222 | + | if snapshot.Epoch == 0 || snapshot.Revision == 0 { | |
| 223 | + | return false | |
| 224 | + | } | |
| 225 | + | if err := snapshot.Validate(); err != nil { | |
| 226 | + | return false | |
| 227 | + | } | |
| 228 | + | if !snapshot.Supersedes(c.snapshot) { | |
| 229 | + | return false | |
| 230 | + | } | |
| 231 | + | if c.snapshot.Epoch != 0 && snapshot.Epoch != c.snapshot.Epoch { | |
| 232 | + | // A new broker process is a full resynchronization: every key, row, and | |
| 233 | + | // retired fence from the previous epoch is invalid. | |
| 234 | + | c.resetLocked() | |
| 235 | + | } | |
| 236 | + | c.snapshot = snapshot.Clone() | |
| 237 | + | c.mergeHostsLocked() | |
| 238 | + | c.projectLocked() | |
| 239 | + | return true | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // Epoch reports the applied broker epoch, or zero before the first publication. | |
| 243 | + | func (c *pickerCatalogue) Epoch() ports.BrokerEpoch { | |
| 244 | + | if c == nil { | |
| 245 | + | return 0 | |
| 246 | + | } | |
| 247 | + | c.mu.Lock() | |
| 248 | + | defer c.mu.Unlock() | |
| 249 | + | return c.snapshot.Epoch | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // Revision reports the applied broker revision, or zero before the first | |
| 253 | + | // publication. | |
| 254 | + | func (c *pickerCatalogue) Revision() ports.BrokerRevision { | |
| 255 | + | if c == nil { | |
| 256 | + | return 0 | |
| 257 | + | } | |
| 258 | + | c.mu.Lock() | |
| 259 | + | defer c.mu.Unlock() | |
| 260 | + | return c.snapshot.Revision | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | // Snapshot returns a defensive copy of the applied broker snapshot. | |
| 264 | + | func (c *pickerCatalogue) Snapshot() ports.BrokerSnapshot { | |
| 265 | + | if c == nil { | |
| 266 | + | return ports.BrokerSnapshot{} | |
| 267 | + | } | |
| 268 | + | c.mu.Lock() | |
| 269 | + | defer c.mu.Unlock() | |
| 270 | + | return c.snapshot.Clone() | |
| 271 | + | } | |
| 272 | + | ||
| 273 | + | // Lines returns a copy of the current ordered rows. | |
| 274 | + | func (c *pickerCatalogue) Lines() []protocol.PickerLine { | |
| 275 | + | if c == nil { | |
| 276 | + | return nil | |
| 277 | + | } | |
| 278 | + | c.mu.Lock() | |
| 279 | + | defer c.mu.Unlock() | |
| 280 | + | return append([]protocol.PickerLine(nil), c.lines...) | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // Cursor returns the projection's cursor hint. | |
| 284 | + | func (c *pickerCatalogue) Cursor() protocol.PickerCursor { | |
| 285 | + | if c == nil { | |
| 286 | + | return protocol.PickerCursor{} | |
| 287 | + | } | |
| 288 | + | c.mu.Lock() | |
| 289 | + | defer c.mu.Unlock() | |
| 290 | + | return c.cursor | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // projection returns the current rows and cursor hint together. | |
| 294 | + | func (c *pickerCatalogue) projection() ([]protocol.PickerLine, protocol.PickerCursor) { | |
| 295 | + | if c == nil { | |
| 296 | + | return nil, protocol.PickerCursor{} | |
| 297 | + | } | |
| 298 | + | c.mu.Lock() | |
| 299 | + | defer c.mu.Unlock() | |
| 300 | + | return append([]protocol.PickerLine(nil), c.lines...), c.cursor | |
| 301 | + | } | |
| 302 | + | ||
| 303 | + | // Ref returns the exact selection identity recorded for one row key, including | |
| 304 | + | // a retired key that has left the current projection. | |
| 305 | + | func (c *pickerCatalogue) Ref(key string) (pickerSelectionRef, bool) { | |
| 306 | + | if c == nil { | |
| 307 | + | return pickerSelectionRef{}, false | |
| 308 | + | } | |
| 309 | + | c.mu.Lock() | |
| 310 | + | defer c.mu.Unlock() | |
| 311 | + | if ref, ok := c.refs[key]; ok { | |
| 312 | + | return ref, true | |
| 313 | + | } | |
| 314 | + | ref, ok := c.retired[key] | |
| 315 | + | return ref, ok | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | // Resolve revalidates one row key against the LATEST applied snapshot and | |
| 319 | + | // returns the exact broker stream request it names. It refuses with a typed | |
| 320 | + | // refusal when the identity is gone, replaced, stale, incompatible, or | |
| 321 | + | // unavailable, and it never invents identity. | |
| 322 | + | func (c *pickerCatalogue) Resolve(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |
| 323 | + | if c == nil { | |
| 324 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"} | |
| 325 | + | } | |
| 326 | + | c.mu.Lock() | |
| 327 | + | defer c.mu.Unlock() | |
| 328 | + | ref, ok := c.refs[key] | |
| 329 | + | if !ok { | |
| 330 | + | ref, ok = c.retired[key] | |
| 331 | + | } | |
| 332 | + | if !ok { | |
| 333 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"} | |
| 334 | + | } | |
| 335 | + | return c.resolveRefLocked(ref, base) | |
| 336 | + | } | |
| 337 | + | ||
| 338 | + | // ResolveRef revalidates an exact selection identity a driver captured when it | |
| 339 | + | // displayed the row. It is the entry point for a selection held across a | |
| 340 | + | // broker epoch or an equivalent output boundary, where the key alone would be | |
| 341 | + | // re-bound to a newer identity. | |
| 342 | + | func (c *pickerCatalogue) ResolveRef(ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |
| 343 | + | if c == nil { | |
| 344 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"} | |
| 345 | + | } | |
| 346 | + | c.mu.Lock() | |
| 347 | + | defer c.mu.Unlock() | |
| 348 | + | return c.resolveRefLocked(ref, base) | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | // ResolveCreation revalidates a named or ephemeral creation against the latest | |
| 352 | + | // snapshot. The created identity is not invented: the daemon must be present, | |
| 353 | + | // compatible, available, and fresh, and a named creation needs a valid session | |
| 354 | + | // name. | |
| 355 | + | func (c *pickerCatalogue) ResolveCreation(local bool, endpoint string, kind pickerSelectionKind, name string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |
| 356 | + | if c == nil { | |
| 357 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"} | |
| 358 | + | } | |
| 359 | + | if kind != pickerSelectionCreateNamed && kind != pickerSelectionCreateEphemeral { | |
| 360 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "not a creation selection"} | |
| 361 | + | } | |
| 362 | + | c.mu.Lock() | |
| 363 | + | defer c.mu.Unlock() | |
| 364 | + | ref := pickerSelectionRef{kind: kind, epoch: c.snapshot.Epoch, local: local, endpoint: endpoint, createName: name} | |
| 365 | + | if !local { | |
| 366 | + | host := c.hostForEndpointLocked(endpoint) | |
| 367 | + | if host == nil { | |
| 368 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"} | |
| 369 | + | } | |
| 370 | + | ref.registration = host.observation.Registration | |
| 371 | + | } | |
| 372 | + | return c.resolveRefLocked(ref, base) | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | func (c *pickerCatalogue) resetLocked() { | |
| 376 | + | c.hosts = make(map[string]*pickerHostState) | |
| 377 | + | c.order = nil | |
| 378 | + | c.refs = make(map[string]pickerSelectionRef) | |
| 379 | + | c.retired = make(map[string]pickerSelectionRef) | |
| 380 | + | c.retiredOrder = nil | |
| 381 | + | c.lines = nil | |
| 382 | + | c.cursor = protocol.PickerCursor{} | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | // mergeHostsLocked folds the newest publication into the per-daemon state. A | |
| 386 | + | // daemon keeps its observed state when the incoming observation regresses, and | |
| 387 | + | // an incarnation change replaces the daemon's identity wholesale. | |
| 388 | + | func (c *pickerCatalogue) mergeHostsLocked() { | |
| 389 | + | next := make(map[string]*pickerHostState, len(c.snapshot.Daemons)) | |
| 390 | + | for i := range c.snapshot.Daemons { | |
| 391 | + | observation := c.snapshot.Daemons[i] | |
| 392 | + | key := pickerDaemonKey(observation.Local, observation.Endpoint) | |
| 393 | + | current, ok := c.hosts[key] | |
| 394 | + | if ok && pickerSameIncarnation(current.observation, observation) && !pickerObservationNewer(observation, current.observation) { | |
| 395 | + | // The publication is newer but this daemon's own observation is not: | |
| 396 | + | // keep the newer observed state and still apply configured authority. | |
| 397 | + | next[key] = &pickerHostState{ | |
| 398 | + | observation: pickerMergeAuthority(current.observation, observation), | |
| 399 | + | } | |
| 400 | + | continue | |
| 401 | + | } | |
| 402 | + | next[key] = &pickerHostState{observation: observation} | |
| 403 | + | } | |
| 404 | + | c.hosts = next | |
| 405 | + | c.order = pickerStableHostOrder(c.order, c.snapshot.Daemons) | |
| 406 | + | // Tombstones fence a retired registration even if a publication still | |
| 407 | + | // carries it; a re-added endpoint with a fresh incarnation is never fenced. | |
| 408 | + | for _, tombstone := range c.snapshot.Removed { | |
| 409 | + | for key, host := range c.hosts { | |
| 410 | + | if !host.observation.Local && host.observation.Endpoint == tombstone.Endpoint && tombstone.Fences(host.observation.Registration) { | |
| 411 | + | delete(c.hosts, key) | |
| 412 | + | } | |
| 413 | + | } | |
| 414 | + | } | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | // projectLocked rebuilds the ordered rows and their opaque keys from the | |
| 418 | + | // current host state. It is deterministic: the same inputs always produce the | |
| 419 | + | // same keys, order, and actions. | |
| 420 | + | func (c *pickerCatalogue) projectLocked() { | |
| 421 | + | now := c.clock.Now() | |
| 422 | + | refs := make(map[string]pickerSelectionRef) | |
| 423 | + | lines := make([]protocol.PickerLine, 0, len(c.hosts)*2) | |
| 424 | + | for _, key := range c.order { | |
| 425 | + | host, ok := c.hosts[key] | |
| 426 | + | if !ok { | |
| 427 | + | continue | |
| 428 | + | } | |
| 429 | + | observation := host.observation | |
| 430 | + | fresh := c.freshLocked(observation, now) | |
| 431 | + | compatible := pickerObservationCompatible(observation) | |
| 432 | + | available := observation.Availability == domain.RemoteAvailabilityReachable | |
| 433 | + | eligible := compatible && available && fresh | |
| 434 | + | origin := pickerOriginLabel(observation) | |
| 435 | + | ||
| 436 | + | lines = append(lines, protocol.PickerLine{Kind: protocol.PickerLineSection, Label: origin, Dim: true}) | |
| 437 | + | lines = append(lines, protocol.PickerLine{ | |
| 438 | + | Key: pickerHostRowKey(observation), | |
| 439 | + | Kind: protocol.PickerLineHost, | |
| 440 | + | Label: origin, | |
| 441 | + | Detail: pickerHostDetail(observation), | |
| 442 | + | Status: pickerObservationStatus(observation, fresh), | |
| 443 | + | StatusDetail: pickerObservationReason(observation, fresh), | |
| 444 | + | Focusable: true, | |
| 445 | + | }) | |
| 446 | + | ||
| 447 | + | for i := range observation.Sessions { | |
| 448 | + | session := observation.Sessions[i] | |
| 449 | + | if session.LifecycleID == (domain.SessionLifecycleID{}) { | |
| 450 | + | // A session without a lifecycle cannot be addressed exactly and | |
| 451 | + | // is never invented into one. | |
| 452 | + | continue | |
| 453 | + | } | |
| 454 | + | ref := pickerSelectionRef{ | |
| 455 | + | kind: pickerSelectionExact, | |
| 456 | + | epoch: c.snapshot.Epoch, | |
| 457 | + | local: observation.Local, | |
| 458 | + | endpoint: observation.Endpoint, | |
| 459 | + | registration: observation.Registration, | |
| 460 | + | lifecycle: session.LifecycleID, | |
| 461 | + | name: session.Name, | |
| 462 | + | } | |
| 463 | + | rowKey := pickerSessionRowKey(ref) | |
| 464 | + | refs[rowKey] = ref | |
| 465 | + | selectable := eligible && session.State != catalogue.RemoteCatalogSessionBroken | |
| 466 | + | actions := protocol.PickerLineActions(0) | |
| 467 | + | if selectable { | |
| 468 | + | actions = protocol.PickerCanNavigate | |
| 469 | + | } | |
| 470 | + | reason := pickerObservationReason(observation, fresh) | |
| 471 | + | if reason == "" { | |
| 472 | + | reason = session.Reason | |
| 473 | + | } | |
| 474 | + | lines = append(lines, protocol.PickerLine{ | |
| 475 | + | Key: rowKey, | |
| 476 | + | Kind: protocol.PickerLineSession, | |
| 477 | + | Label: session.Name, | |
| 478 | + | Detail: pickerSessionDetail(session), | |
| 479 | + | Status: pickerSessionStatus(session), | |
| 480 | + | StatusDetail: reason, | |
| 481 | + | Stopped: session.State == catalogue.RemoteCatalogSessionDown, | |
| 482 | + | Dim: !selectable, | |
| 483 | + | Focusable: true, | |
| 484 | + | Actions: actions, | |
| 485 | + | Ephemeral: session.Ephemeral, | |
| 486 | + | }) | |
| 487 | + | } | |
| 488 | + | } | |
| 489 | + | c.moveRetiredLocked(refs) | |
| 490 | + | c.refs = refs | |
| 491 | + | c.lines = lines | |
| 492 | + | c.cursor = pickerCatalogueCursor(lines) | |
| 493 | + | } | |
| 494 | + | ||
| 495 | + | // moveRetiredLocked keeps the last known ref of a key that left the current | |
| 496 | + | // projection so a late selection can still be classified as gone or replaced. | |
| 497 | + | func (c *pickerCatalogue) moveRetiredLocked(next map[string]pickerSelectionRef) { | |
| 498 | + | for key, ref := range c.refs { | |
| 499 | + | if _, ok := next[key]; ok { | |
| 500 | + | continue | |
| 501 | + | } | |
| 502 | + | if _, exists := c.retired[key]; !exists { | |
| 503 | + | c.retiredOrder = append(c.retiredOrder, key) | |
| 504 | + | } | |
| 505 | + | c.retired[key] = ref | |
| 506 | + | } | |
| 507 | + | for len(c.retiredOrder) > pickerCatalogueMaxRetiredRefs { | |
| 508 | + | oldest := c.retiredOrder[0] | |
| 509 | + | c.retiredOrder = c.retiredOrder[1:] | |
| 510 | + | delete(c.retired, oldest) | |
| 511 | + | } | |
| 512 | + | } | |
| 513 | + | ||
| 514 | + | func (c *pickerCatalogue) resolveRefLocked(ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |
| 515 | + | if c.snapshot.Epoch == 0 { | |
| 516 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"} | |
| 517 | + | } | |
| 518 | + | if ref.epoch != 0 && ref.epoch != c.snapshot.Epoch { | |
| 519 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"} | |
| 520 | + | } | |
| 521 | + | host := c.hostForRefLocked(ref) | |
| 522 | + | if host == nil { | |
| 523 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"} | |
| 524 | + | } | |
| 525 | + | observation := host.observation | |
| 526 | + | if !ref.local && observation.Registration.Incarnation != ref.registration.Incarnation { | |
| 527 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "host identity was replaced"} | |
| 528 | + | } | |
| 529 | + | // Availability is decided before compatibility: an unobserved or | |
| 530 | + | // unreachable daemon refuses as unavailable, never as a version mismatch. | |
| 531 | + | // Only the legacy incompatible availability state, or a reachable daemon | |
| 532 | + | // whose observed version differs from the policy, is incompatible. | |
| 533 | + | switch { | |
| 534 | + | case observation.Availability == domain.RemoteAvailabilityIncompatible: | |
| 535 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"} | |
| 536 | + | case observation.Availability != domain.RemoteAvailabilityReachable: | |
| 537 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "host is unavailable"} | |
| 538 | + | case observation.ProtocolVersion == 0: | |
| 539 | + | // Reachable but unobserved: still never reported as a version mismatch. | |
| 540 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "host is unavailable"} | |
| 541 | + | case !pickerObservationCompatible(observation): | |
| 542 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"} | |
| 543 | + | } | |
| 544 | + | if !c.freshLocked(observation, c.clock.Now()) { | |
| 545 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueStale, Text: "host observation is stale"} | |
| 546 | + | } | |
| 547 | + | ||
| 548 | + | request := ports.BrokerOpenStreamRequest{ | |
| 549 | + | Epoch: c.snapshot.Epoch, | |
| 550 | + | Purpose: ports.BrokerStreamAttachment, | |
| 551 | + | Local: ref.local, | |
| 552 | + | Connection: base.Connection, | |
| 553 | + | Stream: base.Stream, | |
| 554 | + | Registration: observation.Registration, | |
| 555 | + | Policy: observation.Policy, | |
| 556 | + | Env: append([]string(nil), base.Env...), | |
| 557 | + | } | |
| 558 | + | if !ref.local { | |
| 559 | + | request.Endpoint = observation.Endpoint | |
| 560 | + | } | |
| 561 | + | switch ref.kind { | |
| 562 | + | case pickerSelectionExact: | |
| 563 | + | session, ok := pickerFindSession(observation.Sessions, ref.lifecycle) | |
| 564 | + | if !ok { | |
| 565 | + | if pickerSessionReplaced(observation.Sessions, ref) { | |
| 566 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"} | |
| 567 | + | } | |
| 568 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"} | |
| 569 | + | } | |
| 570 | + | if session.State == catalogue.RemoteCatalogSessionBroken { | |
| 571 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "session is broken"} | |
| 572 | + | } | |
| 573 | + | request.Admission = ports.BrokerAdmissionExact | |
| 574 | + | request.Target = protocol.ExactSessionTarget{LifecycleID: session.LifecycleID, SessionName: session.Name} | |
| 575 | + | case pickerSelectionCreateNamed: | |
| 576 | + | if err := domain.ValidateSessionName(ref.createName); err != nil { | |
| 577 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"} | |
| 578 | + | } | |
| 579 | + | request.Admission = ports.BrokerAdmissionCreateNamed | |
| 580 | + | request.Name = ref.createName | |
| 581 | + | case pickerSelectionCreateEphemeral: | |
| 582 | + | request.Admission = ports.BrokerAdmissionCreateEphemeral | |
| 583 | + | default: | |
| 584 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker selection kind is unknown"} | |
| 585 | + | } | |
| 586 | + | if err := request.Validate(); err != nil { | |
| 587 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker stream request is not valid"} | |
| 588 | + | } | |
| 589 | + | return request, nil | |
| 590 | + | } | |
| 591 | + | ||
| 592 | + | func (c *pickerCatalogue) hostForRefLocked(ref pickerSelectionRef) *pickerHostState { | |
| 593 | + | if ref.local { | |
| 594 | + | for _, host := range c.hosts { | |
| 595 | + | if host.observation.Local { | |
| 596 | + | return host | |
| 597 | + | } | |
| 598 | + | } | |
| 599 | + | return nil | |
| 600 | + | } | |
| 601 | + | return c.hostForEndpointLocked(ref.endpoint) | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | func (c *pickerCatalogue) hostForEndpointLocked(endpoint string) *pickerHostState { | |
| 605 | + | for _, host := range c.hosts { | |
| 606 | + | if !host.observation.Local && host.observation.Endpoint == endpoint { | |
| 607 | + | return host | |
| 608 | + | } | |
| 609 | + | } | |
| 610 | + | return nil | |
| 611 | + | } | |
| 612 | + | ||
| 613 | + | // freshLocked reports whether LastSuccess is inside the freshness window. An | |
| 614 | + | // observation that never succeeded is never fresh. | |
| 615 | + | func (c *pickerCatalogue) freshLocked(observation ports.BrokerDaemonObservation, now time.Time) bool { | |
| 616 | + | if observation.LastSuccess.IsZero() { | |
| 617 | + | return false | |
| 618 | + | } | |
| 619 | + | return now.Sub(observation.LastSuccess) <= c.freshness | |
| 620 | + | } | |
| 621 | + | ||
| 622 | + | // pickerHostDiagnostic is one bounded, presentation-safe catalogue observation | |
| 623 | + | // the controller surfaces as a toast. | |
| 624 | + | type pickerHostDiagnostic struct { | |
| 625 | + | Key string | |
| 626 | + | Origin string | |
| 627 | + | Status protocol.PickerLineStatus | |
| 628 | + | Message string | |
| 629 | + | } | |
| 630 | + | ||
| 631 | + | // diagnostics reports every projected host that is not a clean, fresh, | |
| 632 | + | // compatible, reachable daemon, so the controller can surface it. | |
| 633 | + | func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic { | |
| 634 | + | if c == nil { | |
| 635 | + | return nil | |
| 636 | + | } | |
| 637 | + | c.mu.Lock() | |
| 638 | + | defer c.mu.Unlock() | |
| 639 | + | now := c.clock.Now() | |
| 640 | + | diagnostics := make([]pickerHostDiagnostic, 0, len(c.hosts)) | |
| 641 | + | for _, key := range c.order { | |
| 642 | + | host, ok := c.hosts[key] | |
| 643 | + | if !ok { | |
| 644 | + | continue | |
| 645 | + | } | |
| 646 | + | observation := host.observation | |
| 647 | + | fresh := c.freshLocked(observation, now) | |
| 648 | + | if pickerObservationCompatible(observation) && observation.Availability == domain.RemoteAvailabilityReachable && fresh && !observation.Checking { | |
| 649 | + | continue | |
| 650 | + | } | |
| 651 | + | diagnostics = append(diagnostics, pickerHostDiagnostic{ | |
| 652 | + | Key: pickerHostRowKey(observation), | |
| 653 | + | Origin: pickerOriginLabel(observation), | |
| 654 | + | Status: pickerObservationStatus(observation, fresh), | |
| 655 | + | Message: pickerObservationReason(observation, fresh), | |
| 656 | + | }) | |
| 657 | + | } | |
| 658 | + | return diagnostics | |
| 659 | + | } | |
| 660 | + | ||
| 661 | + | func pickerDaemonKey(local bool, endpoint string) string { | |
| 662 | + | if local { | |
| 663 | + | return "local" | |
| 664 | + | } | |
| 665 | + | return "remote:" + endpoint | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | // pickerCatalogueOriginFallback is the fixed, bounded label shown when an | |
| 669 | + | // observation carries no display origin. A raw endpoint is never a display | |
| 670 | + | // label. | |
| 671 | + | const pickerCatalogueOriginFallback = "remote" | |
| 672 | + | ||
| 673 | + | func pickerOriginLabel(observation ports.BrokerDaemonObservation) string { | |
| 674 | + | if observation.DisplayOrigin != "" { | |
| 675 | + | return observation.DisplayOrigin | |
| 676 | + | } | |
| 677 | + | if observation.Local { | |
| 678 | + | return "local" | |
| 679 | + | } | |
| 680 | + | return pickerCatalogueOriginFallback | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | // pickerHostRowKey is the stable, opaque key of a daemon's status row. It is | |
| 684 | + | // never a selection: a host row is focusable but not committable. | |
| 685 | + | func pickerHostRowKey(observation ports.BrokerDaemonObservation) string { | |
| 686 | + | return pickerRowKey("h", observation.Local, observation.Endpoint, observation.Registration.Incarnation[:], nil) | |
| 687 | + | } | |
| 688 | + | ||
| 689 | + | // pickerSessionRowKey is the stable, opaque key of one exact session. It is | |
| 690 | + | // derived only from identity (endpoint, registration incarnation, session | |
| 691 | + | // lifecycle), so a rename never changes it and a replaced lifecycle always | |
| 692 | + | // does. | |
| 693 | + | func pickerSessionRowKey(ref pickerSelectionRef) string { | |
| 694 | + | return pickerRowKey("s", ref.local, ref.endpoint, ref.registration.Incarnation[:], ref.lifecycle[:]) | |
| 695 | + | } | |
| 696 | + | ||
| 697 | + | func pickerRowKey(prefix string, local bool, endpoint string, incarnation, lifecycle []byte) string { | |
| 698 | + | digest := sha256.New() | |
| 699 | + | digest.Write([]byte("vev.picker.")) | |
| 700 | + | digest.Write([]byte(prefix)) | |
| 701 | + | digest.Write([]byte{0}) | |
| 702 | + | if local { | |
| 703 | + | digest.Write([]byte("true")) | |
| 704 | + | } else { | |
| 705 | + | digest.Write([]byte("false")) | |
| 706 | + | } | |
| 707 | + | digest.Write([]byte{0}) | |
| 708 | + | digest.Write([]byte(endpoint)) | |
| 709 | + | digest.Write([]byte{0}) | |
| 710 | + | digest.Write(incarnation) | |
| 711 | + | digest.Write([]byte{0}) | |
| 712 | + | digest.Write(lifecycle) | |
| 713 | + | return prefix + base64.RawURLEncoding.EncodeToString(digest.Sum(nil)[:16]) | |
| 714 | + | } | |
| 715 | + | ||
| 716 | + | // pickerStableHostOrder keeps each host's first-seen position and appends new | |
| 717 | + | // hosts in publication order, so an unrelated update never reshuffles rows. | |
| 718 | + | func pickerStableHostOrder(previous []string, daemons []ports.BrokerDaemonObservation) []string { | |
| 719 | + | present := make(map[string]struct{}, len(daemons)) | |
| 720 | + | published := make([]string, 0, len(daemons)) | |
| 721 | + | for i := range daemons { | |
| 722 | + | key := pickerDaemonKey(daemons[i].Local, daemons[i].Endpoint) | |
| 723 | + | if _, ok := present[key]; ok { | |
| 724 | + | continue | |
| 725 | + | } | |
| 726 | + | present[key] = struct{}{} | |
| 727 | + | published = append(published, key) | |
| 728 | + | } | |
| 729 | + | order := make([]string, 0, len(published)) | |
| 730 | + | seen := make(map[string]struct{}, len(published)) | |
| 731 | + | for _, key := range previous { | |
| 732 | + | if _, ok := present[key]; !ok { | |
| 733 | + | continue | |
| 734 | + | } | |
| 735 | + | if _, ok := seen[key]; ok { | |
| 736 | + | continue | |
| 737 | + | } | |
| 738 | + | seen[key] = struct{}{} | |
| 739 | + | order = append(order, key) | |
| 740 | + | } | |
| 741 | + | for _, key := range published { | |
| 742 | + | if _, ok := seen[key]; ok { | |
| 743 | + | continue | |
| 744 | + | } | |
| 745 | + | seen[key] = struct{}{} | |
| 746 | + | order = append(order, key) | |
| 747 | + | } | |
| 748 | + | return order | |
| 749 | + | } | |
| 750 | + | ||
| 751 | + | // pickerObservationCompatible reports whether the observed daemon can accept an | |
| 752 | + | // attachment under its own configured policy. A version mismatch is derived by | |
| 753 | + | // comparing the observation's ProtocolVersion against its Policy.ProtocolVersion, | |
| 754 | + | // and only when the daemon was actually observed (ProtocolVersion != 0). An | |
| 755 | + | // unobserved daemon carries no version and is never compatible; reachability is | |
| 756 | + | // classified separately so an unobserved or unreachable daemon is never | |
| 757 | + | // reported as a version mismatch. | |
| 758 | + | func pickerObservationCompatible(observation ports.BrokerDaemonObservation) bool { | |
| 759 | + | return observation.ProtocolVersion != 0 && observation.ProtocolVersion == observation.Policy.ProtocolVersion | |
| 760 | + | } | |
| 761 | + | ||
| 762 | + | // pickerObservationVersionMismatch reports whether a daemon that was actually | |
| 763 | + | // observed (ProtocolVersion != 0) reports a protocol version that differs from | |
| 764 | + | // its policy's required version. Reachability and observation are classified | |
| 765 | + | // separately, so an unobserved daemon is never a version mismatch. | |
| 766 | + | func pickerObservationVersionMismatch(observation ports.BrokerDaemonObservation) bool { | |
| 767 | + | return observation.ProtocolVersion != 0 && observation.ProtocolVersion != observation.Policy.ProtocolVersion | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | // pickerSameIncarnation reports whether two observations describe the same | |
| 771 | + | // daemon identity, so their observed state may be compared for monotonicity. | |
| 772 | + | func pickerSameIncarnation(current, incoming ports.BrokerDaemonObservation) bool { | |
| 773 | + | return current.Incarnation == incoming.Incarnation | |
| 774 | + | } | |
| 775 | + | ||
| 776 | + | // pickerObservationNewer reports whether an incoming observation is at least as | |
| 777 | + | // new as the current one for the same daemon identity. A different incarnation | |
| 778 | + | // is an identity replacement and always wins. | |
| 779 | + | func pickerObservationNewer(incoming, current ports.BrokerDaemonObservation) bool { | |
| 780 | + | if incoming.Incarnation != current.Incarnation { | |
| 781 | + | return true | |
| 782 | + | } | |
| 783 | + | if incoming.FailureEpisode != current.FailureEpisode { | |
| 784 | + | return incoming.FailureEpisode > current.FailureEpisode | |
| 785 | + | } | |
| 786 | + | return !incoming.LastSuccess.Before(current.LastSuccess) | |
| 787 | + | } | |
| 788 | + | ||
| 789 | + | // pickerMergeAuthority keeps the newer observed state while refreshing every | |
| 790 | + | // field the registry stamps as configured authority. | |
| 791 | + | func pickerMergeAuthority(current, incoming ports.BrokerDaemonObservation) ports.BrokerDaemonObservation { | |
| 792 | + | merged := current | |
| 793 | + | merged.Local = incoming.Local | |
| 794 | + | merged.Endpoint = incoming.Endpoint | |
| 795 | + | merged.DisplayOrigin = incoming.DisplayOrigin | |
| 796 | + | merged.Rank = incoming.Rank | |
| 797 | + | merged.Registration = incoming.Registration | |
| 798 | + | merged.Policy = incoming.Policy | |
| 799 | + | return merged | |
| 800 | + | } | |
| 801 | + | ||
| 802 | + | // pickerObservationStatus classifies one observation into a row badge. It | |
| 803 | + | // mirrors the legacy daemon-served classifier (directorySessionReason): | |
| 804 | + | // availability is decided first, so an unobserved (Availability Unknown) or | |
| 805 | + | // unreachable daemon is never reported as a version mismatch. A version | |
| 806 | + | // mismatch is derived only for a reachable observation whose observed | |
| 807 | + | // ProtocolVersion differs from its policy's required version. | |
| 808 | + | func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bool) protocol.PickerLineStatus { | |
| 809 | + | switch observation.Availability { | |
| 810 | + | case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed: | |
| 811 | + | return protocol.PickerLineStatusDown | |
| 812 | + | case domain.RemoteAvailabilityInvalidResponse: | |
| 813 | + | return protocol.PickerLineStatusError | |
| 814 | + | case domain.RemoteAvailabilityIncompatible: | |
| 815 | + | return protocol.PickerLineStatusVersion | |
| 816 | + | case domain.RemoteAvailabilityReachable: | |
| 817 | + | if pickerObservationVersionMismatch(observation) { | |
| 818 | + | return protocol.PickerLineStatusVersion | |
| 819 | + | } | |
| 820 | + | if observation.ProtocolVersion == 0 || !fresh { | |
| 821 | + | return protocol.PickerLineStatusStale | |
| 822 | + | } | |
| 823 | + | return protocol.PickerLineStatusUp | |
| 824 | + | default: | |
| 825 | + | // Availability Unknown (unobserved) and any out-of-range value are | |
| 826 | + | // never treated as compatible. | |
| 827 | + | return protocol.PickerLineStatusStale | |
| 828 | + | } | |
| 829 | + | } | |
| 830 | + | ||
| 831 | + | // pickerObservationReason classifies one observation into a bounded display | |
| 832 | + | // reason. As with the badge, availability is decided first; the version branch | |
| 833 | + | // applies only to a reachable observation whose observed version differs from | |
| 834 | + | // policy. Unobserved and unknown availability render as refreshing, matching | |
| 835 | + | // the legacy daemon-served classifier. | |
| 836 | + | func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bool) string { | |
| 837 | + | switch observation.Availability { | |
| 838 | + | case domain.RemoteAvailabilityUnreachable: | |
| 839 | + | return domain.RemoteReasonHostUnreachable | |
| 840 | + | case domain.RemoteAvailabilityAuthFailed: | |
| 841 | + | return domain.RemoteReasonAuthFailure | |
| 842 | + | case domain.RemoteAvailabilityInvalidResponse: | |
| 843 | + | return domain.RemoteReasonMalformed | |
| 844 | + | case domain.RemoteAvailabilityIncompatible: | |
| 845 | + | return domain.RemoteReasonVersionMismatch | |
| 846 | + | case domain.RemoteAvailabilityReachable: | |
| 847 | + | if pickerObservationVersionMismatch(observation) { | |
| 848 | + | return domain.RemoteReasonVersionMismatch | |
| 849 | + | } | |
| 850 | + | if observation.ProtocolVersion == 0 { | |
| 851 | + | return domain.RemoteReasonRefreshing | |
| 852 | + | } | |
| 853 | + | if observation.Checking { | |
| 854 | + | return domain.RemoteReasonRefreshing | |
| 855 | + | } | |
| 856 | + | if !fresh { | |
| 857 | + | return domain.RemoteReasonCatalogStale | |
| 858 | + | } | |
| 859 | + | return "" | |
| 860 | + | default: | |
| 861 | + | // Availability Unknown is the unobserved state: it is still being | |
| 862 | + | // discovered, so it renders as refreshing rather than as a failure. | |
| 863 | + | return domain.RemoteReasonRefreshing | |
| 864 | + | } | |
| 865 | + | } | |
| 866 | + | ||
| 867 | + | func pickerHostDetail(observation ports.BrokerDaemonObservation) string { | |
| 868 | + | if !observation.InventoryKnown { | |
| 869 | + | return "catalogue pending" | |
| 870 | + | } | |
| 871 | + | return fmt.Sprintf("%d sessions", len(observation.Sessions)) | |
| 872 | + | } | |
| 873 | + | ||
| 874 | + | func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.PickerLineStatus { | |
| 875 | + | switch session.State { | |
| 876 | + | case catalogue.RemoteCatalogSessionUp: | |
| 877 | + | return protocol.PickerLineStatusUp | |
| 878 | + | case catalogue.RemoteCatalogSessionDown: | |
| 879 | + | return protocol.PickerLineStatusStopped | |
| 880 | + | case catalogue.RemoteCatalogSessionBroken: | |
| 881 | + | return protocol.PickerLineStatusError | |
| 882 | + | default: | |
| 883 | + | return protocol.PickerLineStatusNone | |
| 884 | + | } | |
| 885 | + | } | |
| 886 | + | ||
| 887 | + | func pickerSessionDetail(session catalogue.RemoteCatalogSession) string { | |
| 888 | + | parts := make([]string, 0, 2) | |
| 889 | + | if session.Attached { | |
| 890 | + | parts = append(parts, "attached") | |
| 891 | + | } | |
| 892 | + | if count := len(session.Tabs); count > 0 { | |
| 893 | + | parts = append(parts, fmt.Sprintf("%d tabs", count)) | |
| 894 | + | } | |
| 895 | + | return strings.Join(parts, " · ") | |
| 896 | + | } | |
| 897 | + | ||
| 898 | + | // pickerCatalogueCursor hints the first actionable row, then the first | |
| 899 | + | // inspectable row, so a fresh picker always has a cursor destination when one | |
| 900 | + | // exists. | |
| 901 | + | func pickerCatalogueCursor(lines []protocol.PickerLine) protocol.PickerCursor { | |
| 902 | + | for i, line := range lines { | |
| 903 | + | if line.Key != "" && line.Focusable && line.Actions != 0 { | |
| 904 | + | return protocol.PickerCursor{Key: line.Key, Index: i} | |
| 905 | + | } | |
| 906 | + | } | |
| 907 | + | for i, line := range lines { | |
| 908 | + | if line.Key != "" && line.Focusable { | |
| 909 | + | return protocol.PickerCursor{Key: line.Key, Index: i} | |
| 910 | + | } | |
| 911 | + | } | |
| 912 | + | return protocol.PickerCursor{Index: -1} | |
| 913 | + | } | |
| 914 | + | ||
| 915 | + | func pickerFindSession(sessions []catalogue.RemoteCatalogSession, lifecycle domain.SessionLifecycleID) (catalogue.RemoteCatalogSession, bool) { | |
| 916 | + | for _, session := range sessions { | |
| 917 | + | if session.LifecycleID == lifecycle { | |
| 918 | + | return session, true | |
| 919 | + | } | |
| 920 | + | } | |
| 921 | + | return catalogue.RemoteCatalogSession{}, false | |
| 922 | + | } | |
| 923 | + | ||
| 924 | + | // pickerSessionReplaced reports whether the exact lifecycle vanished but a | |
| 925 | + | // same-name session remains, which is a replacement rather than a removal. | |
| 926 | + | func pickerSessionReplaced(sessions []catalogue.RemoteCatalogSession, ref pickerSelectionRef) bool { | |
| 927 | + | if ref.name == "" { | |
| 928 | + | return false | |
| 929 | + | } | |
| 930 | + | for _, session := range sessions { | |
| 931 | + | if session.LifecycleID != ref.lifecycle && session.Name == ref.name { | |
| 932 | + | return true | |
| 933 | + | } | |
| 934 | + | } | |
| 935 | + | return false | |
| 936 | + | } | |
| @@ -0,0 +1,936 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "crypto/sha256" | |||
| 5 | + | "encoding/base64" | |||
| 6 | + | "errors" | |||
| 7 | + | "fmt" | |||
| 8 | + | "strings" | |||
| 9 | + | "sync" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "github.com/bnema/vev/internal/domain" | |||
| 13 | + | "github.com/bnema/vev/internal/ports" | |||
| 14 | + | "github.com/bnema/vev/internal/protocol" | |||
| 15 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // Client-owned picker catalogue (Plan 001 P5.2b, offline and unactivated). | |||
| 19 | + | // | |||
| 20 | + | // The autonomous supervisor owns the broker connection and the single terminal | |||
| 21 | + | // input lifetime; this file owns the catalogue the picker presents, projected | |||
| 22 | + | // from the broker's own observations rather than from a serving-daemon | |||
| 23 | + | // catalogue. It is the P5.2b replacement source for the daemon-served | |||
| 24 | + | // protocol.PickerSnapshot: rows are derived from ports.BrokerSnapshot, the | |||
| 25 | + | // opaque keys stay client-local, and a selection resolves to an exact | |||
| 26 | + | // ports.BrokerOpenStreamRequest instead of a daemon-side key handoff. | |||
| 27 | + | // | |||
| 28 | + | // The projection preserves configured authority and observed state separately, | |||
| 29 | + | // exactly as ports.BrokerDaemonObservation does: | |||
| 30 | + | // | |||
| 31 | + | // - daemon identity is the local flag or the remote endpoint plus the | |||
| 32 | + | // registration incarnation; a re-registered endpoint is an identity | |||
| 33 | + | // replacement and its rows never survive. | |||
| 34 | + | // - session identity is the catalogue lifecycle ID; a same-name replacement | |||
| 35 | + | // resolves as "replaced", never by name. | |||
| 36 | + | // - freshness is the LastSuccess window against the injected clock. | |||
| 37 | + | // - compatibility is the observed protocol version against the policy's | |||
| 38 | + | // required version. | |||
| 39 | + | // - availability is the observed RemoteAvailability. | |||
| 40 | + | // | |||
| 41 | + | // Updates are monotonic: a publication that does not supersede the applied | |||
| 42 | + | // epoch/revision is ignored, and a per-daemon observation that regresses | |||
| 43 | + | // (older LastSuccess and no newer failure episode, same incarnation) keeps the | |||
| 44 | + | // newer observed state while still refreshing configured authority. Row order | |||
| 45 | + | // is stable: hosts keep their first-seen position and unrelated updates never | |||
| 46 | + | // reshuffle them. Nothing here attaches to a daemon, starts one, or writes a | |||
| 47 | + | // session. | |||
| 48 | + | ||||
| 49 | + | const ( | |||
| 50 | + | // pickerCatalogueSourceID names the single client-owned picker source. | |||
| 51 | + | pickerCatalogueSourceID = "broker" | |||
| 52 | + | // pickerCatalogueDefaultFreshness bounds how old a LastSuccess may be | |||
| 53 | + | // before a row is displayed as stale and refused at resolution. | |||
| 54 | + | pickerCatalogueDefaultFreshness = 30 * time.Second | |||
| 55 | + | // pickerCatalogueMaxRetiredRefs bounds the retired-key fence used to | |||
| 56 | + | // distinguish "gone" from "replaced" after a row leaves the projection. | |||
| 57 | + | pickerCatalogueMaxRetiredRefs = 128 | |||
| 58 | + | ) | |||
| 59 | + | ||||
| 60 | + | // pickerCatalogueErrorCode is the closed refusal taxonomy for a client-owned | |||
| 61 | + | // picker selection. The zero value is never produced: a refusal always names | |||
| 62 | + | // why the exact selection cannot be resolved. | |||
| 63 | + | type pickerCatalogueErrorCode uint8 | |||
| 64 | + | ||||
| 65 | + | const ( | |||
| 66 | + | pickerCatalogueUnknown pickerCatalogueErrorCode = iota + 1 | |||
| 67 | + | pickerCatalogueGone | |||
| 68 | + | pickerCatalogueReplaced | |||
| 69 | + | pickerCatalogueStale | |||
| 70 | + | pickerCatalogueIncompatible | |||
| 71 | + | pickerCatalogueUnavailable | |||
| 72 | + | pickerCatalogueEpochStale | |||
| 73 | + | pickerCatalogueInvalidName | |||
| 74 | + | pickerCatalogueNoSelection | |||
| 75 | + | ) | |||
| 76 | + | ||||
| 77 | + | func (c pickerCatalogueErrorCode) String() string { | |||
| 78 | + | switch c { | |||
| 79 | + | case pickerCatalogueUnknown: | |||
| 80 | + | return "unknown" | |||
| 81 | + | case pickerCatalogueGone: | |||
| 82 | + | return "gone" | |||
| 83 | + | case pickerCatalogueReplaced: | |||
| 84 | + | return "replaced" | |||
| 85 | + | case pickerCatalogueStale: | |||
| 86 | + | return "stale" | |||
| 87 | + | case pickerCatalogueIncompatible: | |||
| 88 | + | return "incompatible" | |||
| 89 | + | case pickerCatalogueUnavailable: | |||
| 90 | + | return "unavailable" | |||
| 91 | + | case pickerCatalogueEpochStale: | |||
| 92 | + | return "stale_epoch" | |||
| 93 | + | case pickerCatalogueInvalidName: | |||
| 94 | + | return "invalid_name" | |||
| 95 | + | case pickerCatalogueNoSelection: | |||
| 96 | + | return "no_selection" | |||
| 97 | + | default: | |||
| 98 | + | return "unknown" | |||
| 99 | + | } | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | // pickerCatalogueError is a typed, bounded refusal. It never carries a raw | |||
| 103 | + | // endpoint, credential, or adapter diagnostic as display text. | |||
| 104 | + | type pickerCatalogueError struct { | |||
| 105 | + | Code pickerCatalogueErrorCode | |||
| 106 | + | Text string | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func (e pickerCatalogueError) Error() string { | |||
| 110 | + | if e.Text == "" { | |||
| 111 | + | return "vev: picker selection " + e.Code.String() | |||
| 112 | + | } | |||
| 113 | + | return "vev: picker selection " + e.Code.String() + ": " + e.Text | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | // pickerCatalogueErrorIs reports whether err is a typed catalogue refusal with | |||
| 117 | + | // the supplied code, so callers classify without matching message text. | |||
| 118 | + | func pickerCatalogueErrorIs(err error, code pickerCatalogueErrorCode) bool { | |||
| 119 | + | var typed pickerCatalogueError | |||
| 120 | + | return errors.As(err, &typed) && typed.Code == code | |||
| 121 | + | } | |||
| 122 | + | ||||
| 123 | + | // pickerSelectionKind names one closed admission variant a resolved selection | |||
| 124 | + | // can carry. | |||
| 125 | + | type pickerSelectionKind uint8 | |||
| 126 | + | ||||
| 127 | + | const ( | |||
| 128 | + | // pickerSelectionExact attaches to one exact session lifecycle. | |||
| 129 | + | pickerSelectionExact pickerSelectionKind = iota + 1 | |||
| 130 | + | // pickerSelectionCreateNamed creates one named session. | |||
| 131 | + | pickerSelectionCreateNamed | |||
| 132 | + | // pickerSelectionCreateEphemeral creates one ephemeral session. | |||
| 133 | + | pickerSelectionCreateEphemeral | |||
| 134 | + | ) | |||
| 135 | + | ||||
| 136 | + | // pickerSelectionRef is the client's exact selection identity, captured when a | |||
| 137 | + | // row was projected. Resolution revalidates it against the latest snapshot; it | |||
| 138 | + | // is never reconstructed from a display label. | |||
| 139 | + | type pickerSelectionRef struct { | |||
| 140 | + | kind pickerSelectionKind | |||
| 141 | + | epoch ports.BrokerEpoch | |||
| 142 | + | local bool | |||
| 143 | + | endpoint string | |||
| 144 | + | registration domain.RemoteRegistration | |||
| 145 | + | lifecycle domain.SessionLifecycleID | |||
| 146 | + | name string | |||
| 147 | + | createName string | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // pickerResolveBase carries the live broker connection identity a resolved | |||
| 151 | + | // request must be fenced to. The catalogue supplies everything else. | |||
| 152 | + | type pickerResolveBase struct { | |||
| 153 | + | Connection ports.BrokerConnectionID | |||
| 154 | + | Stream ports.BrokerStreamID | |||
| 155 | + | Env []string | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | // pickerHostState is one projected daemon and its newest accepted | |||
| 159 | + | // observation. | |||
| 160 | + | type pickerHostState struct { | |||
| 161 | + | observation ports.BrokerDaemonObservation | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | // pickerCatalogueConfig supplies the fresh-window clock. Clock is optional and | |||
| 165 | + | // falls back to the system clock; Freshness defaults to | |||
| 166 | + | // pickerCatalogueDefaultFreshness. | |||
| 167 | + | type pickerCatalogueConfig struct { | |||
| 168 | + | Clock ports.Clock | |||
| 169 | + | Freshness time.Duration | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // pickerCatalogue is the client-owned, monotonic projection of broker daemon | |||
| 173 | + | // observations into ordered picker rows. It is safe for concurrent use: the | |||
| 174 | + | // supervisor applies publications from its run path while the picker loop | |||
| 175 | + | // resolves selections. | |||
| 176 | + | type pickerCatalogue struct { | |||
| 177 | + | clock ports.Clock | |||
| 178 | + | freshness time.Duration | |||
| 179 | + | ||||
| 180 | + | mu sync.Mutex | |||
| 181 | + | snapshot ports.BrokerSnapshot | |||
| 182 | + | hosts map[string]*pickerHostState | |||
| 183 | + | order []string | |||
| 184 | + | refs map[string]pickerSelectionRef | |||
| 185 | + | retired map[string]pickerSelectionRef | |||
| 186 | + | retiredOrder []string | |||
| 187 | + | lines []protocol.PickerLine | |||
| 188 | + | cursor protocol.PickerCursor | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | // newPickerCatalogue builds an empty projection. | |||
| 192 | + | func newPickerCatalogue(cfg pickerCatalogueConfig) *pickerCatalogue { | |||
| 193 | + | clock := cfg.Clock | |||
| 194 | + | if supervisorNil(clock) { | |||
| 195 | + | clock = systemClock{} | |||
| 196 | + | } | |||
| 197 | + | freshness := cfg.Freshness | |||
| 198 | + | if freshness <= 0 { | |||
| 199 | + | freshness = pickerCatalogueDefaultFreshness | |||
| 200 | + | } | |||
| 201 | + | return &pickerCatalogue{ | |||
| 202 | + | clock: clock, | |||
| 203 | + | freshness: freshness, | |||
| 204 | + | hosts: make(map[string]*pickerHostState), | |||
| 205 | + | refs: make(map[string]pickerSelectionRef), | |||
| 206 | + | retired: make(map[string]pickerSelectionRef), | |||
| 207 | + | } | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // Apply folds one broker publication into the projection. It reports whether | |||
| 211 | + | // the projection changed. The publication is the projection's trust boundary: | |||
| 212 | + | // a snapshot that fails its own contract is rejected outright and the previous | |||
| 213 | + | // projection is kept. A valid publication that does not supersede the applied | |||
| 214 | + | // epoch/revision is also ignored, so a slow or out-of-order snapshot can never | |||
| 215 | + | // regress a row. | |||
| 216 | + | func (c *pickerCatalogue) Apply(snapshot ports.BrokerSnapshot) bool { | |||
| 217 | + | if c == nil { | |||
| 218 | + | return false | |||
| 219 | + | } | |||
| 220 | + | c.mu.Lock() | |||
| 221 | + | defer c.mu.Unlock() | |||
| 222 | + | if snapshot.Epoch == 0 || snapshot.Revision == 0 { | |||
| 223 | + | return false | |||
| 224 | + | } | |||
| 225 | + | if err := snapshot.Validate(); err != nil { | |||
| 226 | + | return false | |||
| 227 | + | } | |||
| 228 | + | if !snapshot.Supersedes(c.snapshot) { | |||
| 229 | + | return false | |||
| 230 | + | } | |||
| 231 | + | if c.snapshot.Epoch != 0 && snapshot.Epoch != c.snapshot.Epoch { | |||
| 232 | + | // A new broker process is a full resynchronization: every key, row, and | |||
| 233 | + | // retired fence from the previous epoch is invalid. | |||
| 234 | + | c.resetLocked() | |||
| 235 | + | } | |||
| 236 | + | c.snapshot = snapshot.Clone() | |||
| 237 | + | c.mergeHostsLocked() | |||
| 238 | + | c.projectLocked() | |||
| 239 | + | return true | |||
| 240 | + | } | |||
| 241 | + | ||||
| 242 | + | // Epoch reports the applied broker epoch, or zero before the first publication. | |||
| 243 | + | func (c *pickerCatalogue) Epoch() ports.BrokerEpoch { | |||
| 244 | + | if c == nil { | |||
| 245 | + | return 0 | |||
| 246 | + | } | |||
| 247 | + | c.mu.Lock() | |||
| 248 | + | defer c.mu.Unlock() | |||
| 249 | + | return c.snapshot.Epoch | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // Revision reports the applied broker revision, or zero before the first | |||
| 253 | + | // publication. | |||
| 254 | + | func (c *pickerCatalogue) Revision() ports.BrokerRevision { | |||
| 255 | + | if c == nil { | |||
| 256 | + | return 0 | |||
| 257 | + | } | |||
| 258 | + | c.mu.Lock() | |||
| 259 | + | defer c.mu.Unlock() | |||
| 260 | + | return c.snapshot.Revision | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | // Snapshot returns a defensive copy of the applied broker snapshot. | |||
| 264 | + | func (c *pickerCatalogue) Snapshot() ports.BrokerSnapshot { | |||
| 265 | + | if c == nil { | |||
| 266 | + | return ports.BrokerSnapshot{} | |||
| 267 | + | } | |||
| 268 | + | c.mu.Lock() | |||
| 269 | + | defer c.mu.Unlock() | |||
| 270 | + | return c.snapshot.Clone() | |||
| 271 | + | } | |||
| 272 | + | ||||
| 273 | + | // Lines returns a copy of the current ordered rows. | |||
| 274 | + | func (c *pickerCatalogue) Lines() []protocol.PickerLine { | |||
| 275 | + | if c == nil { | |||
| 276 | + | return nil | |||
| 277 | + | } | |||
| 278 | + | c.mu.Lock() | |||
| 279 | + | defer c.mu.Unlock() | |||
| 280 | + | return append([]protocol.PickerLine(nil), c.lines...) | |||
| 281 | + | } | |||
| 282 | + | ||||
| 283 | + | // Cursor returns the projection's cursor hint. | |||
| 284 | + | func (c *pickerCatalogue) Cursor() protocol.PickerCursor { | |||
| 285 | + | if c == nil { | |||
| 286 | + | return protocol.PickerCursor{} | |||
| 287 | + | } | |||
| 288 | + | c.mu.Lock() | |||
| 289 | + | defer c.mu.Unlock() | |||
| 290 | + | return c.cursor | |||
| 291 | + | } | |||
| 292 | + | ||||
| 293 | + | // projection returns the current rows and cursor hint together. | |||
| 294 | + | func (c *pickerCatalogue) projection() ([]protocol.PickerLine, protocol.PickerCursor) { | |||
| 295 | + | if c == nil { | |||
| 296 | + | return nil, protocol.PickerCursor{} | |||
| 297 | + | } | |||
| 298 | + | c.mu.Lock() | |||
| 299 | + | defer c.mu.Unlock() | |||
| 300 | + | return append([]protocol.PickerLine(nil), c.lines...), c.cursor | |||
| 301 | + | } | |||
| 302 | + | ||||
| 303 | + | // Ref returns the exact selection identity recorded for one row key, including | |||
| 304 | + | // a retired key that has left the current projection. | |||
| 305 | + | func (c *pickerCatalogue) Ref(key string) (pickerSelectionRef, bool) { | |||
| 306 | + | if c == nil { | |||
| 307 | + | return pickerSelectionRef{}, false | |||
| 308 | + | } | |||
| 309 | + | c.mu.Lock() | |||
| 310 | + | defer c.mu.Unlock() | |||
| 311 | + | if ref, ok := c.refs[key]; ok { | |||
| 312 | + | return ref, true | |||
| 313 | + | } | |||
| 314 | + | ref, ok := c.retired[key] | |||
| 315 | + | return ref, ok | |||
| 316 | + | } | |||
| 317 | + | ||||
| 318 | + | // Resolve revalidates one row key against the LATEST applied snapshot and | |||
| 319 | + | // returns the exact broker stream request it names. It refuses with a typed | |||
| 320 | + | // refusal when the identity is gone, replaced, stale, incompatible, or | |||
| 321 | + | // unavailable, and it never invents identity. | |||
| 322 | + | func (c *pickerCatalogue) Resolve(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |||
| 323 | + | if c == nil { | |||
| 324 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"} | |||
| 325 | + | } | |||
| 326 | + | c.mu.Lock() | |||
| 327 | + | defer c.mu.Unlock() | |||
| 328 | + | ref, ok := c.refs[key] | |||
| 329 | + | if !ok { | |||
| 330 | + | ref, ok = c.retired[key] | |||
| 331 | + | } | |||
| 332 | + | if !ok { | |||
| 333 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker row is not in the catalogue"} | |||
| 334 | + | } | |||
| 335 | + | return c.resolveRefLocked(ref, base) | |||
| 336 | + | } | |||
| 337 | + | ||||
| 338 | + | // ResolveRef revalidates an exact selection identity a driver captured when it | |||
| 339 | + | // displayed the row. It is the entry point for a selection held across a | |||
| 340 | + | // broker epoch or an equivalent output boundary, where the key alone would be | |||
| 341 | + | // re-bound to a newer identity. | |||
| 342 | + | func (c *pickerCatalogue) ResolveRef(ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |||
| 343 | + | if c == nil { | |||
| 344 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"} | |||
| 345 | + | } | |||
| 346 | + | c.mu.Lock() | |||
| 347 | + | defer c.mu.Unlock() | |||
| 348 | + | return c.resolveRefLocked(ref, base) | |||
| 349 | + | } | |||
| 350 | + | ||||
| 351 | + | // ResolveCreation revalidates a named or ephemeral creation against the latest | |||
| 352 | + | // snapshot. The created identity is not invented: the daemon must be present, | |||
| 353 | + | // compatible, available, and fresh, and a named creation needs a valid session | |||
| 354 | + | // name. | |||
| 355 | + | func (c *pickerCatalogue) ResolveCreation(local bool, endpoint string, kind pickerSelectionKind, name string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |||
| 356 | + | if c == nil { | |||
| 357 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "no catalogue"} | |||
| 358 | + | } | |||
| 359 | + | if kind != pickerSelectionCreateNamed && kind != pickerSelectionCreateEphemeral { | |||
| 360 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "not a creation selection"} | |||
| 361 | + | } | |||
| 362 | + | c.mu.Lock() | |||
| 363 | + | defer c.mu.Unlock() | |||
| 364 | + | ref := pickerSelectionRef{kind: kind, epoch: c.snapshot.Epoch, local: local, endpoint: endpoint, createName: name} | |||
| 365 | + | if !local { | |||
| 366 | + | host := c.hostForEndpointLocked(endpoint) | |||
| 367 | + | if host == nil { | |||
| 368 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"} | |||
| 369 | + | } | |||
| 370 | + | ref.registration = host.observation.Registration | |||
| 371 | + | } | |||
| 372 | + | return c.resolveRefLocked(ref, base) | |||
| 373 | + | } | |||
| 374 | + | ||||
| 375 | + | func (c *pickerCatalogue) resetLocked() { | |||
| 376 | + | c.hosts = make(map[string]*pickerHostState) | |||
| 377 | + | c.order = nil | |||
| 378 | + | c.refs = make(map[string]pickerSelectionRef) | |||
| 379 | + | c.retired = make(map[string]pickerSelectionRef) | |||
| 380 | + | c.retiredOrder = nil | |||
| 381 | + | c.lines = nil | |||
| 382 | + | c.cursor = protocol.PickerCursor{} | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | // mergeHostsLocked folds the newest publication into the per-daemon state. A | |||
| 386 | + | // daemon keeps its observed state when the incoming observation regresses, and | |||
| 387 | + | // an incarnation change replaces the daemon's identity wholesale. | |||
| 388 | + | func (c *pickerCatalogue) mergeHostsLocked() { | |||
| 389 | + | next := make(map[string]*pickerHostState, len(c.snapshot.Daemons)) | |||
| 390 | + | for i := range c.snapshot.Daemons { | |||
| 391 | + | observation := c.snapshot.Daemons[i] | |||
| 392 | + | key := pickerDaemonKey(observation.Local, observation.Endpoint) | |||
| 393 | + | current, ok := c.hosts[key] | |||
| 394 | + | if ok && pickerSameIncarnation(current.observation, observation) && !pickerObservationNewer(observation, current.observation) { | |||
| 395 | + | // The publication is newer but this daemon's own observation is not: | |||
| 396 | + | // keep the newer observed state and still apply configured authority. | |||
| 397 | + | next[key] = &pickerHostState{ | |||
| 398 | + | observation: pickerMergeAuthority(current.observation, observation), | |||
| 399 | + | } | |||
| 400 | + | continue | |||
| 401 | + | } | |||
| 402 | + | next[key] = &pickerHostState{observation: observation} | |||
| 403 | + | } | |||
| 404 | + | c.hosts = next | |||
| 405 | + | c.order = pickerStableHostOrder(c.order, c.snapshot.Daemons) | |||
| 406 | + | // Tombstones fence a retired registration even if a publication still | |||
| 407 | + | // carries it; a re-added endpoint with a fresh incarnation is never fenced. | |||
| 408 | + | for _, tombstone := range c.snapshot.Removed { | |||
| 409 | + | for key, host := range c.hosts { | |||
| 410 | + | if !host.observation.Local && host.observation.Endpoint == tombstone.Endpoint && tombstone.Fences(host.observation.Registration) { | |||
| 411 | + | delete(c.hosts, key) | |||
| 412 | + | } | |||
| 413 | + | } | |||
| 414 | + | } | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | // projectLocked rebuilds the ordered rows and their opaque keys from the | |||
| 418 | + | // current host state. It is deterministic: the same inputs always produce the | |||
| 419 | + | // same keys, order, and actions. | |||
| 420 | + | func (c *pickerCatalogue) projectLocked() { | |||
| 421 | + | now := c.clock.Now() | |||
| 422 | + | refs := make(map[string]pickerSelectionRef) | |||
| 423 | + | lines := make([]protocol.PickerLine, 0, len(c.hosts)*2) | |||
| 424 | + | for _, key := range c.order { | |||
| 425 | + | host, ok := c.hosts[key] | |||
| 426 | + | if !ok { | |||
| 427 | + | continue | |||
| 428 | + | } | |||
| 429 | + | observation := host.observation | |||
| 430 | + | fresh := c.freshLocked(observation, now) | |||
| 431 | + | compatible := pickerObservationCompatible(observation) | |||
| 432 | + | available := observation.Availability == domain.RemoteAvailabilityReachable | |||
| 433 | + | eligible := compatible && available && fresh | |||
| 434 | + | origin := pickerOriginLabel(observation) | |||
| 435 | + | ||||
| 436 | + | lines = append(lines, protocol.PickerLine{Kind: protocol.PickerLineSection, Label: origin, Dim: true}) | |||
| 437 | + | lines = append(lines, protocol.PickerLine{ | |||
| 438 | + | Key: pickerHostRowKey(observation), | |||
| 439 | + | Kind: protocol.PickerLineHost, | |||
| 440 | + | Label: origin, | |||
| 441 | + | Detail: pickerHostDetail(observation), | |||
| 442 | + | Status: pickerObservationStatus(observation, fresh), | |||
| 443 | + | StatusDetail: pickerObservationReason(observation, fresh), | |||
| 444 | + | Focusable: true, | |||
| 445 | + | }) | |||
| 446 | + | ||||
| 447 | + | for i := range observation.Sessions { | |||
| 448 | + | session := observation.Sessions[i] | |||
| 449 | + | if session.LifecycleID == (domain.SessionLifecycleID{}) { | |||
| 450 | + | // A session without a lifecycle cannot be addressed exactly and | |||
| 451 | + | // is never invented into one. | |||
| 452 | + | continue | |||
| 453 | + | } | |||
| 454 | + | ref := pickerSelectionRef{ | |||
| 455 | + | kind: pickerSelectionExact, | |||
| 456 | + | epoch: c.snapshot.Epoch, | |||
| 457 | + | local: observation.Local, | |||
| 458 | + | endpoint: observation.Endpoint, | |||
| 459 | + | registration: observation.Registration, | |||
| 460 | + | lifecycle: session.LifecycleID, | |||
| 461 | + | name: session.Name, | |||
| 462 | + | } | |||
| 463 | + | rowKey := pickerSessionRowKey(ref) | |||
| 464 | + | refs[rowKey] = ref | |||
| 465 | + | selectable := eligible && session.State != catalogue.RemoteCatalogSessionBroken | |||
| 466 | + | actions := protocol.PickerLineActions(0) | |||
| 467 | + | if selectable { | |||
| 468 | + | actions = protocol.PickerCanNavigate | |||
| 469 | + | } | |||
| 470 | + | reason := pickerObservationReason(observation, fresh) | |||
| 471 | + | if reason == "" { | |||
| 472 | + | reason = session.Reason | |||
| 473 | + | } | |||
| 474 | + | lines = append(lines, protocol.PickerLine{ | |||
| 475 | + | Key: rowKey, | |||
| 476 | + | Kind: protocol.PickerLineSession, | |||
| 477 | + | Label: session.Name, | |||
| 478 | + | Detail: pickerSessionDetail(session), | |||
| 479 | + | Status: pickerSessionStatus(session), | |||
| 480 | + | StatusDetail: reason, | |||
| 481 | + | Stopped: session.State == catalogue.RemoteCatalogSessionDown, | |||
| 482 | + | Dim: !selectable, | |||
| 483 | + | Focusable: true, | |||
| 484 | + | Actions: actions, | |||
| 485 | + | Ephemeral: session.Ephemeral, | |||
| 486 | + | }) | |||
| 487 | + | } | |||
| 488 | + | } | |||
| 489 | + | c.moveRetiredLocked(refs) | |||
| 490 | + | c.refs = refs | |||
| 491 | + | c.lines = lines | |||
| 492 | + | c.cursor = pickerCatalogueCursor(lines) | |||
| 493 | + | } | |||
| 494 | + | ||||
| 495 | + | // moveRetiredLocked keeps the last known ref of a key that left the current | |||
| 496 | + | // projection so a late selection can still be classified as gone or replaced. | |||
| 497 | + | func (c *pickerCatalogue) moveRetiredLocked(next map[string]pickerSelectionRef) { | |||
| 498 | + | for key, ref := range c.refs { | |||
| 499 | + | if _, ok := next[key]; ok { | |||
| 500 | + | continue | |||
| 501 | + | } | |||
| 502 | + | if _, exists := c.retired[key]; !exists { | |||
| 503 | + | c.retiredOrder = append(c.retiredOrder, key) | |||
| 504 | + | } | |||
| 505 | + | c.retired[key] = ref | |||
| 506 | + | } | |||
| 507 | + | for len(c.retiredOrder) > pickerCatalogueMaxRetiredRefs { | |||
| 508 | + | oldest := c.retiredOrder[0] | |||
| 509 | + | c.retiredOrder = c.retiredOrder[1:] | |||
| 510 | + | delete(c.retired, oldest) | |||
| 511 | + | } | |||
| 512 | + | } | |||
| 513 | + | ||||
| 514 | + | func (c *pickerCatalogue) resolveRefLocked(ref pickerSelectionRef, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |||
| 515 | + | if c.snapshot.Epoch == 0 { | |||
| 516 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "broker catalogue is unavailable"} | |||
| 517 | + | } | |||
| 518 | + | if ref.epoch != 0 && ref.epoch != c.snapshot.Epoch { | |||
| 519 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueEpochStale, Text: "broker epoch changed"} | |||
| 520 | + | } | |||
| 521 | + | host := c.hostForRefLocked(ref) | |||
| 522 | + | if host == nil { | |||
| 523 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "host is no longer in the catalogue"} | |||
| 524 | + | } | |||
| 525 | + | observation := host.observation | |||
| 526 | + | if !ref.local && observation.Registration.Incarnation != ref.registration.Incarnation { | |||
| 527 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "host identity was replaced"} | |||
| 528 | + | } | |||
| 529 | + | // Availability is decided before compatibility: an unobserved or | |||
| 530 | + | // unreachable daemon refuses as unavailable, never as a version mismatch. | |||
| 531 | + | // Only the legacy incompatible availability state, or a reachable daemon | |||
| 532 | + | // whose observed version differs from the policy, is incompatible. | |||
| 533 | + | switch { | |||
| 534 | + | case observation.Availability == domain.RemoteAvailabilityIncompatible: | |||
| 535 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"} | |||
| 536 | + | case observation.Availability != domain.RemoteAvailabilityReachable: | |||
| 537 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "host is unavailable"} | |||
| 538 | + | case observation.ProtocolVersion == 0: | |||
| 539 | + | // Reachable but unobserved: still never reported as a version mismatch. | |||
| 540 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "host is unavailable"} | |||
| 541 | + | case !pickerObservationCompatible(observation): | |||
| 542 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueIncompatible, Text: "host protocol is incompatible"} | |||
| 543 | + | } | |||
| 544 | + | if !c.freshLocked(observation, c.clock.Now()) { | |||
| 545 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueStale, Text: "host observation is stale"} | |||
| 546 | + | } | |||
| 547 | + | ||||
| 548 | + | request := ports.BrokerOpenStreamRequest{ | |||
| 549 | + | Epoch: c.snapshot.Epoch, | |||
| 550 | + | Purpose: ports.BrokerStreamAttachment, | |||
| 551 | + | Local: ref.local, | |||
| 552 | + | Connection: base.Connection, | |||
| 553 | + | Stream: base.Stream, | |||
| 554 | + | Registration: observation.Registration, | |||
| 555 | + | Policy: observation.Policy, | |||
| 556 | + | Env: append([]string(nil), base.Env...), | |||
| 557 | + | } | |||
| 558 | + | if !ref.local { | |||
| 559 | + | request.Endpoint = observation.Endpoint | |||
| 560 | + | } | |||
| 561 | + | switch ref.kind { | |||
| 562 | + | case pickerSelectionExact: | |||
| 563 | + | session, ok := pickerFindSession(observation.Sessions, ref.lifecycle) | |||
| 564 | + | if !ok { | |||
| 565 | + | if pickerSessionReplaced(observation.Sessions, ref) { | |||
| 566 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueReplaced, Text: "session identity was replaced"} | |||
| 567 | + | } | |||
| 568 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueGone, Text: "session is no longer in the catalogue"} | |||
| 569 | + | } | |||
| 570 | + | if session.State == catalogue.RemoteCatalogSessionBroken { | |||
| 571 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnavailable, Text: "session is broken"} | |||
| 572 | + | } | |||
| 573 | + | request.Admission = ports.BrokerAdmissionExact | |||
| 574 | + | request.Target = protocol.ExactSessionTarget{LifecycleID: session.LifecycleID, SessionName: session.Name} | |||
| 575 | + | case pickerSelectionCreateNamed: | |||
| 576 | + | if err := domain.ValidateSessionName(ref.createName); err != nil { | |||
| 577 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueInvalidName, Text: "session name is not valid"} | |||
| 578 | + | } | |||
| 579 | + | request.Admission = ports.BrokerAdmissionCreateNamed | |||
| 580 | + | request.Name = ref.createName | |||
| 581 | + | case pickerSelectionCreateEphemeral: | |||
| 582 | + | request.Admission = ports.BrokerAdmissionCreateEphemeral | |||
| 583 | + | default: | |||
| 584 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker selection kind is unknown"} | |||
| 585 | + | } | |||
| 586 | + | if err := request.Validate(); err != nil { | |||
| 587 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueUnknown, Text: "picker stream request is not valid"} | |||
| 588 | + | } | |||
| 589 | + | return request, nil | |||
| 590 | + | } | |||
| 591 | + | ||||
| 592 | + | func (c *pickerCatalogue) hostForRefLocked(ref pickerSelectionRef) *pickerHostState { | |||
| 593 | + | if ref.local { | |||
| 594 | + | for _, host := range c.hosts { | |||
| 595 | + | if host.observation.Local { | |||
| 596 | + | return host | |||
| 597 | + | } | |||
| 598 | + | } | |||
| 599 | + | return nil | |||
| 600 | + | } | |||
| 601 | + | return c.hostForEndpointLocked(ref.endpoint) | |||
| 602 | + | } | |||
| 603 | + | ||||
| 604 | + | func (c *pickerCatalogue) hostForEndpointLocked(endpoint string) *pickerHostState { | |||
| 605 | + | for _, host := range c.hosts { | |||
| 606 | + | if !host.observation.Local && host.observation.Endpoint == endpoint { | |||
| 607 | + | return host | |||
| 608 | + | } | |||
| 609 | + | } | |||
| 610 | + | return nil | |||
| 611 | + | } | |||
| 612 | + | ||||
| 613 | + | // freshLocked reports whether LastSuccess is inside the freshness window. An | |||
| 614 | + | // observation that never succeeded is never fresh. | |||
| 615 | + | func (c *pickerCatalogue) freshLocked(observation ports.BrokerDaemonObservation, now time.Time) bool { | |||
| 616 | + | if observation.LastSuccess.IsZero() { | |||
| 617 | + | return false | |||
| 618 | + | } | |||
| 619 | + | return now.Sub(observation.LastSuccess) <= c.freshness | |||
| 620 | + | } | |||
| 621 | + | ||||
| 622 | + | // pickerHostDiagnostic is one bounded, presentation-safe catalogue observation | |||
| 623 | + | // the controller surfaces as a toast. | |||
| 624 | + | type pickerHostDiagnostic struct { | |||
| 625 | + | Key string | |||
| 626 | + | Origin string | |||
| 627 | + | Status protocol.PickerLineStatus | |||
| 628 | + | Message string | |||
| 629 | + | } | |||
| 630 | + | ||||
| 631 | + | // diagnostics reports every projected host that is not a clean, fresh, | |||
| 632 | + | // compatible, reachable daemon, so the controller can surface it. | |||
| 633 | + | func (c *pickerCatalogue) diagnostics() []pickerHostDiagnostic { | |||
| 634 | + | if c == nil { | |||
| 635 | + | return nil | |||
| 636 | + | } | |||
| 637 | + | c.mu.Lock() | |||
| 638 | + | defer c.mu.Unlock() | |||
| 639 | + | now := c.clock.Now() | |||
| 640 | + | diagnostics := make([]pickerHostDiagnostic, 0, len(c.hosts)) | |||
| 641 | + | for _, key := range c.order { | |||
| 642 | + | host, ok := c.hosts[key] | |||
| 643 | + | if !ok { | |||
| 644 | + | continue | |||
| 645 | + | } | |||
| 646 | + | observation := host.observation | |||
| 647 | + | fresh := c.freshLocked(observation, now) | |||
| 648 | + | if pickerObservationCompatible(observation) && observation.Availability == domain.RemoteAvailabilityReachable && fresh && !observation.Checking { | |||
| 649 | + | continue | |||
| 650 | + | } | |||
| 651 | + | diagnostics = append(diagnostics, pickerHostDiagnostic{ | |||
| 652 | + | Key: pickerHostRowKey(observation), | |||
| 653 | + | Origin: pickerOriginLabel(observation), | |||
| 654 | + | Status: pickerObservationStatus(observation, fresh), | |||
| 655 | + | Message: pickerObservationReason(observation, fresh), | |||
| 656 | + | }) | |||
| 657 | + | } | |||
| 658 | + | return diagnostics | |||
| 659 | + | } | |||
| 660 | + | ||||
| 661 | + | func pickerDaemonKey(local bool, endpoint string) string { | |||
| 662 | + | if local { | |||
| 663 | + | return "local" | |||
| 664 | + | } | |||
| 665 | + | return "remote:" + endpoint | |||
| 666 | + | } | |||
| 667 | + | ||||
| 668 | + | // pickerCatalogueOriginFallback is the fixed, bounded label shown when an | |||
| 669 | + | // observation carries no display origin. A raw endpoint is never a display | |||
| 670 | + | // label. | |||
| 671 | + | const pickerCatalogueOriginFallback = "remote" | |||
| 672 | + | ||||
| 673 | + | func pickerOriginLabel(observation ports.BrokerDaemonObservation) string { | |||
| 674 | + | if observation.DisplayOrigin != "" { | |||
| 675 | + | return observation.DisplayOrigin | |||
| 676 | + | } | |||
| 677 | + | if observation.Local { | |||
| 678 | + | return "local" | |||
| 679 | + | } | |||
| 680 | + | return pickerCatalogueOriginFallback | |||
| 681 | + | } | |||
| 682 | + | ||||
| 683 | + | // pickerHostRowKey is the stable, opaque key of a daemon's status row. It is | |||
| 684 | + | // never a selection: a host row is focusable but not committable. | |||
| 685 | + | func pickerHostRowKey(observation ports.BrokerDaemonObservation) string { | |||
| 686 | + | return pickerRowKey("h", observation.Local, observation.Endpoint, observation.Registration.Incarnation[:], nil) | |||
| 687 | + | } | |||
| 688 | + | ||||
| 689 | + | // pickerSessionRowKey is the stable, opaque key of one exact session. It is | |||
| 690 | + | // derived only from identity (endpoint, registration incarnation, session | |||
| 691 | + | // lifecycle), so a rename never changes it and a replaced lifecycle always | |||
| 692 | + | // does. | |||
| 693 | + | func pickerSessionRowKey(ref pickerSelectionRef) string { | |||
| 694 | + | return pickerRowKey("s", ref.local, ref.endpoint, ref.registration.Incarnation[:], ref.lifecycle[:]) | |||
| 695 | + | } | |||
| 696 | + | ||||
| 697 | + | func pickerRowKey(prefix string, local bool, endpoint string, incarnation, lifecycle []byte) string { | |||
| 698 | + | digest := sha256.New() | |||
| 699 | + | digest.Write([]byte("vev.picker.")) | |||
| 700 | + | digest.Write([]byte(prefix)) | |||
| 701 | + | digest.Write([]byte{0}) | |||
| 702 | + | if local { | |||
| 703 | + | digest.Write([]byte("true")) | |||
| 704 | + | } else { | |||
| 705 | + | digest.Write([]byte("false")) | |||
| 706 | + | } | |||
| 707 | + | digest.Write([]byte{0}) | |||
| 708 | + | digest.Write([]byte(endpoint)) | |||
| 709 | + | digest.Write([]byte{0}) | |||
| 710 | + | digest.Write(incarnation) | |||
| 711 | + | digest.Write([]byte{0}) | |||
| 712 | + | digest.Write(lifecycle) | |||
| 713 | + | return prefix + base64.RawURLEncoding.EncodeToString(digest.Sum(nil)[:16]) | |||
| 714 | + | } | |||
| 715 | + | ||||
| 716 | + | // pickerStableHostOrder keeps each host's first-seen position and appends new | |||
| 717 | + | // hosts in publication order, so an unrelated update never reshuffles rows. | |||
| 718 | + | func pickerStableHostOrder(previous []string, daemons []ports.BrokerDaemonObservation) []string { | |||
| 719 | + | present := make(map[string]struct{}, len(daemons)) | |||
| 720 | + | published := make([]string, 0, len(daemons)) | |||
| 721 | + | for i := range daemons { | |||
| 722 | + | key := pickerDaemonKey(daemons[i].Local, daemons[i].Endpoint) | |||
| 723 | + | if _, ok := present[key]; ok { | |||
| 724 | + | continue | |||
| 725 | + | } | |||
| 726 | + | present[key] = struct{}{} | |||
| 727 | + | published = append(published, key) | |||
| 728 | + | } | |||
| 729 | + | order := make([]string, 0, len(published)) | |||
| 730 | + | seen := make(map[string]struct{}, len(published)) | |||
| 731 | + | for _, key := range previous { | |||
| 732 | + | if _, ok := present[key]; !ok { | |||
| 733 | + | continue | |||
| 734 | + | } | |||
| 735 | + | if _, ok := seen[key]; ok { | |||
| 736 | + | continue | |||
| 737 | + | } | |||
| 738 | + | seen[key] = struct{}{} | |||
| 739 | + | order = append(order, key) | |||
| 740 | + | } | |||
| 741 | + | for _, key := range published { | |||
| 742 | + | if _, ok := seen[key]; ok { | |||
| 743 | + | continue | |||
| 744 | + | } | |||
| 745 | + | seen[key] = struct{}{} | |||
| 746 | + | order = append(order, key) | |||
| 747 | + | } | |||
| 748 | + | return order | |||
| 749 | + | } | |||
| 750 | + | ||||
| 751 | + | // pickerObservationCompatible reports whether the observed daemon can accept an | |||
| 752 | + | // attachment under its own configured policy. A version mismatch is derived by | |||
| 753 | + | // comparing the observation's ProtocolVersion against its Policy.ProtocolVersion, | |||
| 754 | + | // and only when the daemon was actually observed (ProtocolVersion != 0). An | |||
| 755 | + | // unobserved daemon carries no version and is never compatible; reachability is | |||
| 756 | + | // classified separately so an unobserved or unreachable daemon is never | |||
| 757 | + | // reported as a version mismatch. | |||
| 758 | + | func pickerObservationCompatible(observation ports.BrokerDaemonObservation) bool { | |||
| 759 | + | return observation.ProtocolVersion != 0 && observation.ProtocolVersion == observation.Policy.ProtocolVersion | |||
| 760 | + | } | |||
| 761 | + | ||||
| 762 | + | // pickerObservationVersionMismatch reports whether a daemon that was actually | |||
| 763 | + | // observed (ProtocolVersion != 0) reports a protocol version that differs from | |||
| 764 | + | // its policy's required version. Reachability and observation are classified | |||
| 765 | + | // separately, so an unobserved daemon is never a version mismatch. | |||
| 766 | + | func pickerObservationVersionMismatch(observation ports.BrokerDaemonObservation) bool { | |||
| 767 | + | return observation.ProtocolVersion != 0 && observation.ProtocolVersion != observation.Policy.ProtocolVersion | |||
| 768 | + | } | |||
| 769 | + | ||||
| 770 | + | // pickerSameIncarnation reports whether two observations describe the same | |||
| 771 | + | // daemon identity, so their observed state may be compared for monotonicity. | |||
| 772 | + | func pickerSameIncarnation(current, incoming ports.BrokerDaemonObservation) bool { | |||
| 773 | + | return current.Incarnation == incoming.Incarnation | |||
| 774 | + | } | |||
| 775 | + | ||||
| 776 | + | // pickerObservationNewer reports whether an incoming observation is at least as | |||
| 777 | + | // new as the current one for the same daemon identity. A different incarnation | |||
| 778 | + | // is an identity replacement and always wins. | |||
| 779 | + | func pickerObservationNewer(incoming, current ports.BrokerDaemonObservation) bool { | |||
| 780 | + | if incoming.Incarnation != current.Incarnation { | |||
| 781 | + | return true | |||
| 782 | + | } | |||
| 783 | + | if incoming.FailureEpisode != current.FailureEpisode { | |||
| 784 | + | return incoming.FailureEpisode > current.FailureEpisode | |||
| 785 | + | } | |||
| 786 | + | return !incoming.LastSuccess.Before(current.LastSuccess) | |||
| 787 | + | } | |||
| 788 | + | ||||
| 789 | + | // pickerMergeAuthority keeps the newer observed state while refreshing every | |||
| 790 | + | // field the registry stamps as configured authority. | |||
| 791 | + | func pickerMergeAuthority(current, incoming ports.BrokerDaemonObservation) ports.BrokerDaemonObservation { | |||
| 792 | + | merged := current | |||
| 793 | + | merged.Local = incoming.Local | |||
| 794 | + | merged.Endpoint = incoming.Endpoint | |||
| 795 | + | merged.DisplayOrigin = incoming.DisplayOrigin | |||
| 796 | + | merged.Rank = incoming.Rank | |||
| 797 | + | merged.Registration = incoming.Registration | |||
| 798 | + | merged.Policy = incoming.Policy | |||
| 799 | + | return merged | |||
| 800 | + | } | |||
| 801 | + | ||||
| 802 | + | // pickerObservationStatus classifies one observation into a row badge. It | |||
| 803 | + | // mirrors the legacy daemon-served classifier (directorySessionReason): | |||
| 804 | + | // availability is decided first, so an unobserved (Availability Unknown) or | |||
| 805 | + | // unreachable daemon is never reported as a version mismatch. A version | |||
| 806 | + | // mismatch is derived only for a reachable observation whose observed | |||
| 807 | + | // ProtocolVersion differs from its policy's required version. | |||
| 808 | + | func pickerObservationStatus(observation ports.BrokerDaemonObservation, fresh bool) protocol.PickerLineStatus { | |||
| 809 | + | switch observation.Availability { | |||
| 810 | + | case domain.RemoteAvailabilityUnreachable, domain.RemoteAvailabilityAuthFailed: | |||
| 811 | + | return protocol.PickerLineStatusDown | |||
| 812 | + | case domain.RemoteAvailabilityInvalidResponse: | |||
| 813 | + | return protocol.PickerLineStatusError | |||
| 814 | + | case domain.RemoteAvailabilityIncompatible: | |||
| 815 | + | return protocol.PickerLineStatusVersion | |||
| 816 | + | case domain.RemoteAvailabilityReachable: | |||
| 817 | + | if pickerObservationVersionMismatch(observation) { | |||
| 818 | + | return protocol.PickerLineStatusVersion | |||
| 819 | + | } | |||
| 820 | + | if observation.ProtocolVersion == 0 || !fresh { | |||
| 821 | + | return protocol.PickerLineStatusStale | |||
| 822 | + | } | |||
| 823 | + | return protocol.PickerLineStatusUp | |||
| 824 | + | default: | |||
| 825 | + | // Availability Unknown (unobserved) and any out-of-range value are | |||
| 826 | + | // never treated as compatible. | |||
| 827 | + | return protocol.PickerLineStatusStale | |||
| 828 | + | } | |||
| 829 | + | } | |||
| 830 | + | ||||
| 831 | + | // pickerObservationReason classifies one observation into a bounded display | |||
| 832 | + | // reason. As with the badge, availability is decided first; the version branch | |||
| 833 | + | // applies only to a reachable observation whose observed version differs from | |||
| 834 | + | // policy. Unobserved and unknown availability render as refreshing, matching | |||
| 835 | + | // the legacy daemon-served classifier. | |||
| 836 | + | func pickerObservationReason(observation ports.BrokerDaemonObservation, fresh bool) string { | |||
| 837 | + | switch observation.Availability { | |||
| 838 | + | case domain.RemoteAvailabilityUnreachable: | |||
| 839 | + | return domain.RemoteReasonHostUnreachable | |||
| 840 | + | case domain.RemoteAvailabilityAuthFailed: | |||
| 841 | + | return domain.RemoteReasonAuthFailure | |||
| 842 | + | case domain.RemoteAvailabilityInvalidResponse: | |||
| 843 | + | return domain.RemoteReasonMalformed | |||
| 844 | + | case domain.RemoteAvailabilityIncompatible: | |||
| 845 | + | return domain.RemoteReasonVersionMismatch | |||
| 846 | + | case domain.RemoteAvailabilityReachable: | |||
| 847 | + | if pickerObservationVersionMismatch(observation) { | |||
| 848 | + | return domain.RemoteReasonVersionMismatch | |||
| 849 | + | } | |||
| 850 | + | if observation.ProtocolVersion == 0 { | |||
| 851 | + | return domain.RemoteReasonRefreshing | |||
| 852 | + | } | |||
| 853 | + | if observation.Checking { | |||
| 854 | + | return domain.RemoteReasonRefreshing | |||
| 855 | + | } | |||
| 856 | + | if !fresh { | |||
| 857 | + | return domain.RemoteReasonCatalogStale | |||
| 858 | + | } | |||
| 859 | + | return "" | |||
| 860 | + | default: | |||
| 861 | + | // Availability Unknown is the unobserved state: it is still being | |||
| 862 | + | // discovered, so it renders as refreshing rather than as a failure. | |||
| 863 | + | return domain.RemoteReasonRefreshing | |||
| 864 | + | } | |||
| 865 | + | } | |||
| 866 | + | ||||
| 867 | + | func pickerHostDetail(observation ports.BrokerDaemonObservation) string { | |||
| 868 | + | if !observation.InventoryKnown { | |||
| 869 | + | return "catalogue pending" | |||
| 870 | + | } | |||
| 871 | + | return fmt.Sprintf("%d sessions", len(observation.Sessions)) | |||
| 872 | + | } | |||
| 873 | + | ||||
| 874 | + | func pickerSessionStatus(session catalogue.RemoteCatalogSession) protocol.PickerLineStatus { | |||
| 875 | + | switch session.State { | |||
| 876 | + | case catalogue.RemoteCatalogSessionUp: | |||
| 877 | + | return protocol.PickerLineStatusUp | |||
| 878 | + | case catalogue.RemoteCatalogSessionDown: | |||
| 879 | + | return protocol.PickerLineStatusStopped | |||
| 880 | + | case catalogue.RemoteCatalogSessionBroken: | |||
| 881 | + | return protocol.PickerLineStatusError | |||
| 882 | + | default: | |||
| 883 | + | return protocol.PickerLineStatusNone | |||
| 884 | + | } | |||
| 885 | + | } | |||
| 886 | + | ||||
| 887 | + | func pickerSessionDetail(session catalogue.RemoteCatalogSession) string { | |||
| 888 | + | parts := make([]string, 0, 2) | |||
| 889 | + | if session.Attached { | |||
| 890 | + | parts = append(parts, "attached") | |||
| 891 | + | } | |||
| 892 | + | if count := len(session.Tabs); count > 0 { | |||
| 893 | + | parts = append(parts, fmt.Sprintf("%d tabs", count)) | |||
| 894 | + | } | |||
| 895 | + | return strings.Join(parts, " · ") | |||
| 896 | + | } | |||
| 897 | + | ||||
| 898 | + | // pickerCatalogueCursor hints the first actionable row, then the first | |||
| 899 | + | // inspectable row, so a fresh picker always has a cursor destination when one | |||
| 900 | + | // exists. | |||
| 901 | + | func pickerCatalogueCursor(lines []protocol.PickerLine) protocol.PickerCursor { | |||
| 902 | + | for i, line := range lines { | |||
| 903 | + | if line.Key != "" && line.Focusable && line.Actions != 0 { | |||
| 904 | + | return protocol.PickerCursor{Key: line.Key, Index: i} | |||
| 905 | + | } | |||
| 906 | + | } | |||
| 907 | + | for i, line := range lines { | |||
| 908 | + | if line.Key != "" && line.Focusable { | |||
| 909 | + | return protocol.PickerCursor{Key: line.Key, Index: i} | |||
| 910 | + | } | |||
| 911 | + | } | |||
| 912 | + | return protocol.PickerCursor{Index: -1} | |||
| 913 | + | } | |||
| 914 | + | ||||
| 915 | + | func pickerFindSession(sessions []catalogue.RemoteCatalogSession, lifecycle domain.SessionLifecycleID) (catalogue.RemoteCatalogSession, bool) { | |||
| 916 | + | for _, session := range sessions { | |||
| 917 | + | if session.LifecycleID == lifecycle { | |||
| 918 | + | return session, true | |||
| 919 | + | } | |||
| 920 | + | } | |||
| 921 | + | return catalogue.RemoteCatalogSession{}, false | |||
| 922 | + | } | |||
| 923 | + | ||||
| 924 | + | // pickerSessionReplaced reports whether the exact lifecycle vanished but a | |||
| 925 | + | // same-name session remains, which is a replacement rather than a removal. | |||
| 926 | + | func pickerSessionReplaced(sessions []catalogue.RemoteCatalogSession, ref pickerSelectionRef) bool { | |||
| 927 | + | if ref.name == "" { | |||
| 928 | + | return false | |||
| 929 | + | } | |||
| 930 | + | for _, session := range sessions { | |||
| 931 | + | if session.LifecycleID != ref.lifecycle && session.Name == ref.name { | |||
| 932 | + | return true | |||
| 933 | + | } | |||
| 934 | + | } | |||
| 935 | + | return false | |||
| 936 | + | } | |||
A internal/usecase/client/picker_catalogue_test.go +941 -0
| @@ -0,0 +1,941 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "sync" | |
| 6 | + | "testing" | |
| 7 | + | "time" | |
| 8 | + | ||
| 9 | + | "github.com/stretchr/testify/require" | |
| 10 | + | ||
| 11 | + | "github.com/bnema/vev/internal/domain" | |
| 12 | + | "github.com/bnema/vev/internal/ports" | |
| 13 | + | "github.com/bnema/vev/internal/protocol" | |
| 14 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // Client-owned picker catalogue tests (Plan 001 P5.2b). Every case is | |
| 18 | + | // deterministic: the fake clock, not wall time, decides freshness, and no case | |
| 19 | + | // sleeps. | |
| 20 | + | ||
| 21 | + | const pickerTestFreshness = 30 * time.Second | |
| 22 | + | ||
| 23 | + | func pickerTestPolicy() ports.BrokerPolicy { | |
| 24 | + | return ports.BrokerPolicy{ | |
| 25 | + | ProtocolVersion: protocol.Version, | |
| 26 | + | CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |
| 27 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |
| 28 | + | Transport: "quic", | |
| 29 | + | Trust: "trust", | |
| 30 | + | Launch: "launch", | |
| 31 | + | Isolation: "user", | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func pickerTestIncarnation(seed byte) [16]byte { | |
| 36 | + | var id [16]byte | |
| 37 | + | id[0] = seed | |
| 38 | + | return id | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func pickerTestRegistration(endpoint string, seed byte) domain.RemoteRegistration { | |
| 42 | + | return domain.RemoteRegistration{Endpoint: endpoint, Incarnation: pickerTestIncarnation(seed), Generation: 1} | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func pickerTestLifecycle(seed byte) domain.SessionLifecycleID { return pickerTestIncarnation(seed) } | |
| 46 | + | ||
| 47 | + | func pickerTestSession(name string, seed byte, state catalogue.RemoteCatalogSessionState) catalogue.RemoteCatalogSession { | |
| 48 | + | return catalogue.RemoteCatalogSession{LifecycleID: pickerTestLifecycle(seed), Name: name, State: state} | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func pickerTestSessionWithLifecycle(name string, lifecycle domain.SessionLifecycleID) catalogue.RemoteCatalogSession { | |
| 52 | + | return catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: name, State: catalogue_Up} | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func pickerTestLocalObservation(now time.Time, sessions ...catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation { | |
| 56 | + | return ports.BrokerDaemonObservation{ | |
| 57 | + | Local: true, | |
| 58 | + | DisplayOrigin: "local", | |
| 59 | + | Policy: pickerTestPolicy(), | |
| 60 | + | Identity: "local-daemon", | |
| 61 | + | Incarnation: ports.BrokerDaemonIncarnation(pickerTestIncarnation(9)), | |
| 62 | + | ProtocolVersion: protocol.Version, | |
| 63 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 64 | + | LastSuccess: now, | |
| 65 | + | InventoryKnown: true, | |
| 66 | + | Sessions: sessions, | |
| 67 | + | } | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func pickerTestRemoteObservation(endpoint string, registrationSeed, incarnationSeed byte, now time.Time, sessions ...catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation { | |
| 71 | + | return ports.BrokerDaemonObservation{ | |
| 72 | + | Endpoint: endpoint, | |
| 73 | + | DisplayOrigin: endpoint, | |
| 74 | + | Registration: pickerTestRegistration(endpoint, registrationSeed), | |
| 75 | + | Policy: pickerTestPolicy(), | |
| 76 | + | Identity: ports.BrokerDaemonIdentity("identity-" + endpoint), | |
| 77 | + | Incarnation: ports.BrokerDaemonIncarnation(pickerTestIncarnation(incarnationSeed)), | |
| 78 | + | ProtocolVersion: protocol.Version, | |
| 79 | + | Availability: domain.RemoteAvailabilityReachable, | |
| 80 | + | LastSuccess: now, | |
| 81 | + | InventoryKnown: true, | |
| 82 | + | Sessions: sessions, | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func pickerTestUnobservedObservation(now time.Time, sessions ...catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation { | |
| 87 | + | return ports.BrokerDaemonObservation{ | |
| 88 | + | Local: true, | |
| 89 | + | DisplayOrigin: "local", | |
| 90 | + | Policy: pickerTestPolicy(), | |
| 91 | + | Availability: domain.RemoteAvailabilityUnknown, | |
| 92 | + | InventoryKnown: len(sessions) > 0, | |
| 93 | + | Sessions: sessions, | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func advancePickerClock(clock *supervisorTestClock, delta time.Duration) { | |
| 98 | + | clock.mu.Lock() | |
| 99 | + | clock.now = clock.now.Add(delta) | |
| 100 | + | clock.mu.Unlock() | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | func pickerTestCatalogue(t *testing.T) (*pickerCatalogue, *supervisorTestClock) { | |
| 104 | + | t.Helper() | |
| 105 | + | clock := newSupervisorTestClock() | |
| 106 | + | return newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: pickerTestFreshness}), clock | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func pickerLineByLabel(lines []protocol.PickerLine, label string) (protocol.PickerLine, bool) { | |
| 110 | + | for _, line := range lines { | |
| 111 | + | if line.Kind != protocol.PickerLineSection && line.Label == label { | |
| 112 | + | return line, true | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | return protocol.PickerLine{}, false | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func pickerSectionLabels(lines []protocol.PickerLine) []string { | |
| 119 | + | sections := make([]string, 0) | |
| 120 | + | for _, line := range lines { | |
| 121 | + | if line.Kind == protocol.PickerLineSection { | |
| 122 | + | sections = append(sections, line.Label) | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | return sections | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func pickerSessionLabels(lines []protocol.PickerLine) []string { | |
| 129 | + | labels := make([]string, 0) | |
| 130 | + | for _, line := range lines { | |
| 131 | + | if line.Kind == protocol.PickerLineSession { | |
| 132 | + | labels = append(labels, line.Label) | |
| 133 | + | } | |
| 134 | + | } | |
| 135 | + | return labels | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func pickerTestBase() pickerResolveBase { | |
| 139 | + | return pickerResolveBase{Connection: ports.BrokerConnectionID{1}, Stream: ports.BrokerStreamID(1)} | |
| 140 | + | } | |
| 141 | + | ||
| 142 | + | func TestPickerCatalogueEmptySnapshot(t *testing.T) { | |
| 143 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 144 | + | require.False(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 0, Revision: 1}), "an invalid snapshot is never applied") | |
| 145 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 5, Revision: 1})) | |
| 146 | + | require.Empty(t, catalogue.Lines()) | |
| 147 | + | require.Equal(t, ports.BrokerEpoch(5), catalogue.Epoch()) | |
| 148 | + | require.Equal(t, ports.BrokerRevision(1), catalogue.Revision()) | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestPickerCatalogueProjectionCases(t *testing.T) { | |
| 152 | + | clock := newSupervisorTestClock() | |
| 153 | + | now := clock.Now() | |
| 154 | + | ||
| 155 | + | tests := []struct { | |
| 156 | + | name string | |
| 157 | + | daemons []ports.BrokerDaemonObservation | |
| 158 | + | wantSections []string | |
| 159 | + | wantSessions []string | |
| 160 | + | wantSessionKeys map[string]bool // label -> action available | |
| 161 | + | wantStatus map[string]protocol.PickerLineStatus | |
| 162 | + | wantDim map[string]bool | |
| 163 | + | }{ | |
| 164 | + | { | |
| 165 | + | name: "local only", | |
| 166 | + | daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up))}, | |
| 167 | + | wantSections: []string{"local"}, | |
| 168 | + | wantSessions: []string{"alpha", "beta"}, | |
| 169 | + | wantSessionKeys: map[string]bool{"alpha": true, "beta": true}, | |
| 170 | + | }, | |
| 171 | + | { | |
| 172 | + | name: "unobserved local daemon still projects", | |
| 173 | + | daemons: []ports.BrokerDaemonObservation{pickerTestUnobservedObservation(now)}, | |
| 174 | + | wantSections: []string{"local"}, | |
| 175 | + | wantSessions: []string{}, | |
| 176 | + | wantStatus: map[string]protocol.PickerLineStatus{"local": protocol.PickerLineStatusStale}, | |
| 177 | + | }, | |
| 178 | + | { | |
| 179 | + | name: "no local daemon entry", | |
| 180 | + | daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))}, | |
| 181 | + | wantSections: []string{"user@arch"}, | |
| 182 | + | wantSessions: []string{"remote-a"}, | |
| 183 | + | wantSessionKeys: map[string]bool{"remote-a": true}, | |
| 184 | + | }, | |
| 185 | + | { | |
| 186 | + | name: "stale observation", | |
| 187 | + | daemons: []ports.BrokerDaemonObservation{ | |
| 188 | + | func() ports.BrokerDaemonObservation { | |
| 189 | + | observation := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |
| 190 | + | observation.LastSuccess = now.Add(-2 * pickerTestFreshness) | |
| 191 | + | return observation | |
| 192 | + | }(), | |
| 193 | + | }, | |
| 194 | + | wantSections: []string{"local"}, | |
| 195 | + | wantSessions: []string{"alpha"}, | |
| 196 | + | wantSessionKeys: map[string]bool{"alpha": false}, | |
| 197 | + | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp, "local": protocol.PickerLineStatusStale}, | |
| 198 | + | wantDim: map[string]bool{"alpha": true}, | |
| 199 | + | }, | |
| 200 | + | { | |
| 201 | + | name: "unavailable host", | |
| 202 | + | daemons: []ports.BrokerDaemonObservation{ | |
| 203 | + | func() ports.BrokerDaemonObservation { | |
| 204 | + | observation := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up)) | |
| 205 | + | observation.Availability = domain.RemoteAvailabilityUnreachable | |
| 206 | + | return observation | |
| 207 | + | }(), | |
| 208 | + | }, | |
| 209 | + | wantSections: []string{"user@arch"}, | |
| 210 | + | wantSessions: []string{"remote-a"}, | |
| 211 | + | wantSessionKeys: map[string]bool{"remote-a": false}, | |
| 212 | + | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusDown}, | |
| 213 | + | wantDim: map[string]bool{"remote-a": true}, | |
| 214 | + | }, | |
| 215 | + | { | |
| 216 | + | name: "incompatible host", | |
| 217 | + | daemons: []ports.BrokerDaemonObservation{ | |
| 218 | + | func() ports.BrokerDaemonObservation { | |
| 219 | + | observation := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up)) | |
| 220 | + | observation.ProtocolVersion = protocol.Version + 1 | |
| 221 | + | return observation | |
| 222 | + | }(), | |
| 223 | + | }, | |
| 224 | + | wantSections: []string{"user@arch"}, | |
| 225 | + | wantSessions: []string{"remote-a"}, | |
| 226 | + | wantSessionKeys: map[string]bool{"remote-a": false}, | |
| 227 | + | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusVersion}, | |
| 228 | + | wantDim: map[string]bool{"remote-a": true}, | |
| 229 | + | }, | |
| 230 | + | { | |
| 231 | + | name: "broken session", | |
| 232 | + | daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken))}, | |
| 233 | + | wantSections: []string{"local"}, | |
| 234 | + | wantSessions: []string{"alpha"}, | |
| 235 | + | wantSessionKeys: map[string]bool{"alpha": false}, | |
| 236 | + | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusError}, | |
| 237 | + | wantDim: map[string]bool{"alpha": true}, | |
| 238 | + | }, | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | for _, tt := range tests { | |
| 242 | + | t.Run(tt.name, func(t *testing.T) { | |
| 243 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 244 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 7, Revision: 1, Daemons: tt.daemons})) | |
| 245 | + | lines := catalogue.Lines() | |
| 246 | + | require.Equal(t, tt.wantSections, pickerSectionLabels(lines)) | |
| 247 | + | require.Equal(t, tt.wantSessions, pickerSessionLabels(lines)) | |
| 248 | + | for label, actionable := range tt.wantSessionKeys { | |
| 249 | + | line, ok := pickerLineByLabel(lines, label) | |
| 250 | + | require.True(t, ok, "session %q must be projected", label) | |
| 251 | + | require.True(t, line.Focusable, "a projected row is always a cursor destination") | |
| 252 | + | if actionable { | |
| 253 | + | require.Equal(t, protocol.PickerCanNavigate, line.Actions, "an eligible row admits navigation") | |
| 254 | + | } else { | |
| 255 | + | require.Zero(t, line.Actions, "a refused row is inspectable but never committable") | |
| 256 | + | } | |
| 257 | + | } | |
| 258 | + | for label, status := range tt.wantStatus { | |
| 259 | + | line, ok := pickerLineByLabel(lines, label) | |
| 260 | + | require.True(t, ok, "row %q must be projected", label) | |
| 261 | + | require.Equal(t, status, line.Status, "row %q badge", label) | |
| 262 | + | } | |
| 263 | + | for label, dim := range tt.wantDim { | |
| 264 | + | line, ok := pickerLineByLabel(lines, label) | |
| 265 | + | require.True(t, ok) | |
| 266 | + | require.Equal(t, dim, line.Dim, "row %q dim", label) | |
| 267 | + | } | |
| 268 | + | }) | |
| 269 | + | } | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | func TestPickerCatalogueMonotonicUpdates(t *testing.T) { | |
| 273 | + | clock := newSupervisorTestClock() | |
| 274 | + | now := clock.Now() | |
| 275 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 276 | + | ||
| 277 | + | newer := ports.BrokerSnapshot{Epoch: 3, Revision: 4, Daemons: []ports.BrokerDaemonObservation{ | |
| 278 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |
| 279 | + | }} | |
| 280 | + | require.True(t, catalogue.Apply(newer)) | |
| 281 | + | before := catalogue.Lines() | |
| 282 | + | ||
| 283 | + | // A lower revision in the same epoch never supersedes and never regresses. | |
| 284 | + | older := ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ | |
| 285 | + | pickerTestLocalObservation(now, pickerTestSession("beta", 2, catalogue_Up)), | |
| 286 | + | }} | |
| 287 | + | require.False(t, catalogue.Apply(older)) | |
| 288 | + | require.Equal(t, before, catalogue.Lines()) | |
| 289 | + | require.Equal(t, ports.BrokerRevision(4), catalogue.Revision()) | |
| 290 | + | ||
| 291 | + | // A new epoch is a full resynchronization and replaces every row. | |
| 292 | + | epoch := ports.BrokerSnapshot{Epoch: 4, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 293 | + | pickerTestLocalObservation(now, pickerTestSession("gamma", 3, catalogue_Up)), | |
| 294 | + | }} | |
| 295 | + | require.True(t, catalogue.Apply(epoch)) | |
| 296 | + | require.Equal(t, []string{"gamma"}, pickerSessionLabels(catalogue.Lines())) | |
| 297 | + | } | |
| 298 | + | ||
| 299 | + | // catalogue_Up/Broken keep the tables above short without importing the | |
| 300 | + | // catalogue state into every literal. | |
| 301 | + | const ( | |
| 302 | + | catalogue_Up = catalogue.RemoteCatalogSessionState("up") | |
| 303 | + | catalogue_Broken = catalogue.RemoteCatalogSessionState("broken") | |
| 304 | + | ) | |
| 305 | + | ||
| 306 | + | func TestPickerCatalogueSlowObservationKeepsNewerState(t *testing.T) { | |
| 307 | + | clock := newSupervisorTestClock() | |
| 308 | + | now := clock.Now() | |
| 309 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 310 | + | ||
| 311 | + | fresh := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up)) | |
| 312 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{fresh}})) | |
| 313 | + | ||
| 314 | + | regressed := pickerTestRemoteObservation("user@arch", 1, 1, now.Add(-time.Minute), pickerTestSession("beta", 2, catalogue_Up)) | |
| 315 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 3, Daemons: []ports.BrokerDaemonObservation{regressed}}), "the publication supersedes") | |
| 316 | + | ||
| 317 | + | require.Equal(t, []string{"alpha"}, pickerSessionLabels(catalogue.Lines()), "an older observed inventory never regresses a row") | |
| 318 | + | } | |
| 319 | + | ||
| 320 | + | func TestPickerCatalogueIdentityReplacement(t *testing.T) { | |
| 321 | + | clock := newSupervisorTestClock() | |
| 322 | + | now := clock.Now() | |
| 323 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 324 | + | ||
| 325 | + | original := ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 326 | + | pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up)), | |
| 327 | + | }} | |
| 328 | + | require.True(t, catalogue.Apply(original)) | |
| 329 | + | oldLine, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |
| 330 | + | require.True(t, ok) | |
| 331 | + | oldKey := oldLine.Key | |
| 332 | + | ||
| 333 | + | replaced := ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ | |
| 334 | + | pickerTestRemoteObservation("user@arch", 2, 2, now, pickerTestSession("alpha", 9, catalogue_Up)), | |
| 335 | + | }} | |
| 336 | + | require.True(t, catalogue.Apply(replaced)) | |
| 337 | + | newLine, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |
| 338 | + | require.True(t, ok) | |
| 339 | + | require.NotEqual(t, oldKey, newLine.Key, "a replaced identity gets a new opaque key") | |
| 340 | + | ||
| 341 | + | // The old key is still recognized and refused as a replacement, not silently | |
| 342 | + | // re-bound to the new session. | |
| 343 | + | _, err := catalogue.Resolve(oldKey, pickerTestBase()) | |
| 344 | + | require.Error(t, err) | |
| 345 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueReplaced), "same endpoint with a new incarnation is a replacement") | |
| 346 | + | } | |
| 347 | + | ||
| 348 | + | func TestPickerCatalogueStableRowOrder(t *testing.T) { | |
| 349 | + | clock := newSupervisorTestClock() | |
| 350 | + | now := clock.Now() | |
| 351 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 352 | + | ||
| 353 | + | first := ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 354 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |
| 355 | + | pickerTestRemoteObservation("host-a", 1, 1, now, pickerTestSession("a-one", 2, catalogue_Up)), | |
| 356 | + | pickerTestRemoteObservation("host-b", 2, 2, now, pickerTestSession("b-one", 3, catalogue_Up)), | |
| 357 | + | }} | |
| 358 | + | require.True(t, catalogue.Apply(first)) | |
| 359 | + | require.Equal(t, []string{"local", "host-a", "host-b"}, pickerSectionLabels(catalogue.Lines())) | |
| 360 | + | ||
| 361 | + | // The publication reorders the remote hosts and changes one unrelated | |
| 362 | + | // session; the first-seen order never reshuffles. The local daemon stays | |
| 363 | + | // first, as every valid broker publication requires. | |
| 364 | + | second := ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ | |
| 365 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |
| 366 | + | pickerTestRemoteObservation("host-b", 2, 2, now, pickerTestSession("b-one", 3, catalogue_Up), pickerTestSession("b-two", 4, catalogue_Up)), | |
| 367 | + | pickerTestRemoteObservation("host-a", 1, 1, now, pickerTestSession("a-one", 2, catalogue_Up)), | |
| 368 | + | }} | |
| 369 | + | require.True(t, catalogue.Apply(second)) | |
| 370 | + | require.Equal(t, []string{"local", "host-a", "host-b"}, pickerSectionLabels(catalogue.Lines())) | |
| 371 | + | require.Equal(t, []string{"alpha", "a-one", "b-one", "b-two"}, pickerSessionLabels(catalogue.Lines())) | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | func TestPickerCatalogueDeterministicKeys(t *testing.T) { | |
| 375 | + | clock := newSupervisorTestClock() | |
| 376 | + | now := clock.Now() | |
| 377 | + | daemons := []ports.BrokerDaemonObservation{ | |
| 378 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |
| 379 | + | pickerTestRemoteObservation("host-a", 1, 1, now, pickerTestSession("a-one", 2, catalogue_Up)), | |
| 380 | + | } | |
| 381 | + | first, _ := pickerTestCatalogue(t) | |
| 382 | + | second, _ := pickerTestCatalogue(t) | |
| 383 | + | require.True(t, first.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: daemons})) | |
| 384 | + | require.True(t, second.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: daemons})) | |
| 385 | + | require.Equal(t, first.Lines(), second.Lines()) | |
| 386 | + | } | |
| 387 | + | ||
| 388 | + | func TestPickerCatalogueSelectionResolution(t *testing.T) { | |
| 389 | + | clock := newSupervisorTestClock() | |
| 390 | + | now := clock.Now() | |
| 391 | + | base := pickerTestBase() | |
| 392 | + | ||
| 393 | + | tests := []struct { | |
| 394 | + | name string | |
| 395 | + | build func(t *testing.T, catalogue *pickerCatalogue, clock *supervisorTestClock) string | |
| 396 | + | wantErr pickerCatalogueErrorCode | |
| 397 | + | wantLocal bool | |
| 398 | + | wantAdmission ports.BrokerStreamAdmission | |
| 399 | + | }{ | |
| 400 | + | { | |
| 401 | + | name: "local exact attaches to the lifecycle", | |
| 402 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |
| 403 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 404 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |
| 405 | + | }})) | |
| 406 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |
| 407 | + | require.True(t, ok) | |
| 408 | + | return line.Key | |
| 409 | + | }, | |
| 410 | + | wantLocal: true, | |
| 411 | + | wantAdmission: ports.BrokerAdmissionExact, | |
| 412 | + | }, | |
| 413 | + | { | |
| 414 | + | name: "remote exact carries the configured authority", | |
| 415 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |
| 416 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 417 | + | pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)), | |
| 418 | + | }})) | |
| 419 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a") | |
| 420 | + | require.True(t, ok) | |
| 421 | + | return line.Key | |
| 422 | + | }, | |
| 423 | + | wantAdmission: ports.BrokerAdmissionExact, | |
| 424 | + | }, | |
| 425 | + | { | |
| 426 | + | name: "gone host", | |
| 427 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |
| 428 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 429 | + | pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)), | |
| 430 | + | }})) | |
| 431 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a") | |
| 432 | + | require.True(t, ok) | |
| 433 | + | key := line.Key | |
| 434 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2})) | |
| 435 | + | return key | |
| 436 | + | }, | |
| 437 | + | wantErr: pickerCatalogueGone, | |
| 438 | + | }, | |
| 439 | + | { | |
| 440 | + | name: "stale observation", | |
| 441 | + | build: func(t *testing.T, catalogue *pickerCatalogue, clock *supervisorTestClock) string { | |
| 442 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 443 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |
| 444 | + | }})) | |
| 445 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |
| 446 | + | require.True(t, ok) | |
| 447 | + | advancePickerClock(clock, 2*pickerTestFreshness) | |
| 448 | + | return line.Key | |
| 449 | + | }, | |
| 450 | + | wantErr: pickerCatalogueStale, | |
| 451 | + | }, | |
| 452 | + | { | |
| 453 | + | name: "incompatible host", | |
| 454 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |
| 455 | + | incompatible := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)) | |
| 456 | + | incompatible.ProtocolVersion = protocol.Version + 1 | |
| 457 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{incompatible}})) | |
| 458 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a") | |
| 459 | + | require.True(t, ok) | |
| 460 | + | return line.Key | |
| 461 | + | }, | |
| 462 | + | wantErr: pickerCatalogueIncompatible, | |
| 463 | + | }, | |
| 464 | + | { | |
| 465 | + | name: "unavailable host", | |
| 466 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |
| 467 | + | unavailable := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)) | |
| 468 | + | unavailable.Availability = domain.RemoteAvailabilityUnreachable | |
| 469 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unavailable}})) | |
| 470 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a") | |
| 471 | + | require.True(t, ok) | |
| 472 | + | return line.Key | |
| 473 | + | }, | |
| 474 | + | wantErr: pickerCatalogueUnavailable, | |
| 475 | + | }, | |
| 476 | + | { | |
| 477 | + | name: "epoch change", | |
| 478 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |
| 479 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 480 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |
| 481 | + | }})) | |
| 482 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |
| 483 | + | require.True(t, ok) | |
| 484 | + | key := line.Key | |
| 485 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 4, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 486 | + | pickerTestLocalObservation(now, pickerTestSession("beta", 2, catalogue_Up)), | |
| 487 | + | }})) | |
| 488 | + | return key | |
| 489 | + | }, | |
| 490 | + | wantErr: pickerCatalogueUnknown, | |
| 491 | + | }, | |
| 492 | + | { | |
| 493 | + | name: "session lifecycle replaced", | |
| 494 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |
| 495 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 496 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |
| 497 | + | }})) | |
| 498 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |
| 499 | + | require.True(t, ok) | |
| 500 | + | key := line.Key | |
| 501 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ | |
| 502 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 5, catalogue_Up)), | |
| 503 | + | }})) | |
| 504 | + | return key | |
| 505 | + | }, | |
| 506 | + | wantErr: pickerCatalogueReplaced, | |
| 507 | + | }, | |
| 508 | + | { | |
| 509 | + | name: "session gone", | |
| 510 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |
| 511 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 512 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up)), | |
| 513 | + | }})) | |
| 514 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |
| 515 | + | require.True(t, ok) | |
| 516 | + | key := line.Key | |
| 517 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ | |
| 518 | + | pickerTestLocalObservation(now, pickerTestSession("beta", 2, catalogue_Up)), | |
| 519 | + | }})) | |
| 520 | + | return key | |
| 521 | + | }, | |
| 522 | + | wantErr: pickerCatalogueGone, | |
| 523 | + | }, | |
| 524 | + | { | |
| 525 | + | name: "broken session", | |
| 526 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |
| 527 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 528 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken)), | |
| 529 | + | }})) | |
| 530 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |
| 531 | + | require.True(t, ok) | |
| 532 | + | return line.Key | |
| 533 | + | }, | |
| 534 | + | wantErr: pickerCatalogueUnavailable, | |
| 535 | + | }, | |
| 536 | + | } | |
| 537 | + | ||
| 538 | + | for _, tt := range tests { | |
| 539 | + | t.Run(tt.name, func(t *testing.T) { | |
| 540 | + | catalogue, clock := pickerTestCatalogue(t) | |
| 541 | + | key := tt.build(t, catalogue, clock) | |
| 542 | + | request, err := catalogue.Resolve(key, base) | |
| 543 | + | if tt.wantErr != 0 { | |
| 544 | + | require.Error(t, err) | |
| 545 | + | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | |
| 546 | + | return | |
| 547 | + | } | |
| 548 | + | require.NoError(t, err) | |
| 549 | + | require.NoError(t, request.Validate(), "a resolved request always validates") | |
| 550 | + | require.Equal(t, ports.BrokerStreamAttachment, request.Purpose) | |
| 551 | + | require.Equal(t, tt.wantAdmission, request.Admission) | |
| 552 | + | require.Equal(t, tt.wantLocal, request.Local) | |
| 553 | + | require.Equal(t, ports.BrokerEpoch(3), request.Epoch) | |
| 554 | + | require.Equal(t, base.Connection, request.Connection) | |
| 555 | + | require.Equal(t, base.Stream, request.Stream) | |
| 556 | + | }) | |
| 557 | + | } | |
| 558 | + | } | |
| 559 | + | ||
| 560 | + | func TestPickerCatalogueRemoteSelectionCarriesExactIdentity(t *testing.T) { | |
| 561 | + | clock := newSupervisorTestClock() | |
| 562 | + | now := clock.Now() | |
| 563 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 564 | + | registration := pickerTestRegistration("user@arch", 1) | |
| 565 | + | lifecycle := pickerTestLifecycle(2) | |
| 566 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 567 | + | pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)), | |
| 568 | + | }})) | |
| 569 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a") | |
| 570 | + | require.True(t, ok) | |
| 571 | + | ||
| 572 | + | request, err := catalogue.Resolve(line.Key, pickerTestBase()) | |
| 573 | + | require.NoError(t, err) | |
| 574 | + | require.Equal(t, "user@arch", request.Endpoint) | |
| 575 | + | require.Equal(t, registration, request.Registration) | |
| 576 | + | require.Equal(t, protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "remote-a"}, request.Target) | |
| 577 | + | require.Equal(t, pickerTestPolicy(), request.Policy) | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | func TestPickerCatalogueCreationResolution(t *testing.T) { | |
| 581 | + | clock := newSupervisorTestClock() | |
| 582 | + | now := clock.Now() | |
| 583 | + | base := pickerTestBase() | |
| 584 | + | ||
| 585 | + | tests := []struct { | |
| 586 | + | name string | |
| 587 | + | local bool | |
| 588 | + | endpoint string | |
| 589 | + | kind pickerSelectionKind | |
| 590 | + | sessionName string | |
| 591 | + | snapshot func() ports.BrokerSnapshot | |
| 592 | + | wantErr pickerCatalogueErrorCode | |
| 593 | + | wantAdmission ports.BrokerStreamAdmission | |
| 594 | + | }{ | |
| 595 | + | { | |
| 596 | + | name: "named creation on the local daemon", | |
| 597 | + | local: true, | |
| 598 | + | kind: pickerSelectionCreateNamed, | |
| 599 | + | sessionName: "fresh", | |
| 600 | + | snapshot: func() ports.BrokerSnapshot { | |
| 601 | + | return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now)}} | |
| 602 | + | }, | |
| 603 | + | wantAdmission: ports.BrokerAdmissionCreateNamed, | |
| 604 | + | }, | |
| 605 | + | { | |
| 606 | + | name: "ephemeral creation on a remote daemon", | |
| 607 | + | endpoint: "user@arch", | |
| 608 | + | kind: pickerSelectionCreateEphemeral, | |
| 609 | + | snapshot: func() ports.BrokerSnapshot { | |
| 610 | + | return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now)}} | |
| 611 | + | }, | |
| 612 | + | wantAdmission: ports.BrokerAdmissionCreateEphemeral, | |
| 613 | + | }, | |
| 614 | + | { | |
| 615 | + | name: "named creation without a name", | |
| 616 | + | local: true, | |
| 617 | + | kind: pickerSelectionCreateNamed, | |
| 618 | + | snapshot: func() ports.BrokerSnapshot { | |
| 619 | + | return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now)}} | |
| 620 | + | }, | |
| 621 | + | wantErr: pickerCatalogueInvalidName, | |
| 622 | + | }, | |
| 623 | + | { | |
| 624 | + | name: "creation on a missing remote daemon", | |
| 625 | + | endpoint: "user@arch", | |
| 626 | + | kind: pickerSelectionCreateEphemeral, | |
| 627 | + | snapshot: func() ports.BrokerSnapshot { return ports.BrokerSnapshot{Epoch: 3, Revision: 1} }, | |
| 628 | + | wantErr: pickerCatalogueGone, | |
| 629 | + | }, | |
| 630 | + | { | |
| 631 | + | name: "creation on an incompatible daemon", | |
| 632 | + | local: true, | |
| 633 | + | kind: pickerSelectionCreateEphemeral, | |
| 634 | + | snapshot: func() ports.BrokerSnapshot { | |
| 635 | + | observation := pickerTestLocalObservation(now) | |
| 636 | + | observation.ProtocolVersion = protocol.Version + 1 | |
| 637 | + | return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{observation}} | |
| 638 | + | }, | |
| 639 | + | wantErr: pickerCatalogueIncompatible, | |
| 640 | + | }, | |
| 641 | + | } | |
| 642 | + | ||
| 643 | + | for _, tt := range tests { | |
| 644 | + | t.Run(tt.name, func(t *testing.T) { | |
| 645 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 646 | + | require.True(t, catalogue.Apply(tt.snapshot())) | |
| 647 | + | request, err := catalogue.ResolveCreation(tt.local, tt.endpoint, tt.kind, tt.sessionName, base) | |
| 648 | + | if tt.wantErr != 0 { | |
| 649 | + | require.Error(t, err) | |
| 650 | + | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | |
| 651 | + | return | |
| 652 | + | } | |
| 653 | + | require.NoError(t, err) | |
| 654 | + | require.NoError(t, request.Validate()) | |
| 655 | + | require.Equal(t, tt.wantAdmission, request.Admission) | |
| 656 | + | }) | |
| 657 | + | } | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | func TestPickerCatalogueUnknownKeyRefused(t *testing.T) { | |
| 661 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 662 | + | _, err := catalogue.Resolve("missing", pickerTestBase()) | |
| 663 | + | require.Error(t, err) | |
| 664 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnknown)) | |
| 665 | + | } | |
| 666 | + | ||
| 667 | + | // TestPickerCatalogueEpochFenceOnCapturedRef pins the selection revision: a | |
| 668 | + | // driver that captured a row's exact identity at one broker epoch may not | |
| 669 | + | // resolve it after the broker process was replaced, even when the endpoint and | |
| 670 | + | // session lifecycle happen to look the same. | |
| 671 | + | func TestPickerCatalogueEpochFenceOnCapturedRef(t *testing.T) { | |
| 672 | + | clock := newSupervisorTestClock() | |
| 673 | + | now := clock.Now() | |
| 674 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 675 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 676 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |
| 677 | + | }})) | |
| 678 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |
| 679 | + | require.True(t, ok) | |
| 680 | + | ref, ok := catalogue.Ref(line.Key) | |
| 681 | + | require.True(t, ok) | |
| 682 | + | ||
| 683 | + | // A new broker epoch resynchronizes the catalogue; the captured identity is | |
| 684 | + | // fenced even though its key is recomputed identically. | |
| 685 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 4, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |
| 686 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |
| 687 | + | }})) | |
| 688 | + | _, err := catalogue.ResolveRef(ref, pickerTestBase()) | |
| 689 | + | require.Error(t, err) | |
| 690 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueEpochStale)) | |
| 691 | + | } | |
| 692 | + | ||
| 693 | + | func TestPickerCatalogueRetiredRefFenceIsBounded(t *testing.T) { | |
| 694 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 695 | + | base := pickerTestBase() | |
| 696 | + | const inserted = pickerCatalogueMaxRetiredRefs + 8 | |
| 697 | + | keys := make([]string, 0, inserted) | |
| 698 | + | for i := range inserted { | |
| 699 | + | name := fmt.Sprintf("s-%d", i) | |
| 700 | + | session := pickerTestSession(name, byte(i%200+1), catalogue_Up) | |
| 701 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: ports.BrokerRevision(i + 1), Daemons: []ports.BrokerDaemonObservation{ | |
| 702 | + | pickerTestLocalObservation(time.Unix(1000, 0), session), | |
| 703 | + | }})) | |
| 704 | + | line, ok := pickerLineByLabel(catalogue.Lines(), name) | |
| 705 | + | require.True(t, ok) | |
| 706 | + | keys = append(keys, line.Key) | |
| 707 | + | } | |
| 708 | + | // The fence holds the exact bound: only the newest retired keys survive. | |
| 709 | + | require.Len(t, catalogue.retired, pickerCatalogueMaxRetiredRefs) | |
| 710 | + | require.Len(t, catalogue.retiredOrder, pickerCatalogueMaxRetiredRefs) | |
| 711 | + | ||
| 712 | + | // A retained retired key still classifies as gone: its lifecycle left the | |
| 713 | + | // projection and no same-name replacement exists. | |
| 714 | + | retainedKey := keys[len(keys)-2] | |
| 715 | + | _, err := catalogue.Resolve(retainedKey, base) | |
| 716 | + | require.Error(t, err) | |
| 717 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueGone), "a retained retired key must classify as gone: %v", err) | |
| 718 | + | ||
| 719 | + | // The oldest key was evicted from the bounded fence and is now unknown. | |
| 720 | + | evictedKey := keys[0] | |
| 721 | + | _, err = catalogue.Resolve(evictedKey, base) | |
| 722 | + | require.Error(t, err) | |
| 723 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnknown), "an evicted retired key must classify as unknown: %v", err) | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | // TestPickerCatalogueRetiredRefFenceConcurrentApply exercises the retired-ref | |
| 727 | + | // fence under -race: many goroutines fold distinct publications concurrently | |
| 728 | + | // and the fence may never exceed its bound. | |
| 729 | + | func TestPickerCatalogueRetiredRefFenceConcurrentApply(t *testing.T) { | |
| 730 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 731 | + | const ( | |
| 732 | + | workers = 8 | |
| 733 | + | perWorker = 64 | |
| 734 | + | ) | |
| 735 | + | var wait sync.WaitGroup | |
| 736 | + | for worker := range workers { | |
| 737 | + | wait.Add(1) | |
| 738 | + | go func(worker int) { | |
| 739 | + | defer wait.Done() | |
| 740 | + | for i := range perWorker { | |
| 741 | + | var lifecycle domain.SessionLifecycleID | |
| 742 | + | lifecycle[0] = byte(i + 1) | |
| 743 | + | lifecycle[1] = byte(worker + 1) | |
| 744 | + | session := pickerTestSessionWithLifecycle(fmt.Sprintf("w%d-%d", worker, i), lifecycle) | |
| 745 | + | catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: ports.BrokerRevision(worker*perWorker + i + 1), Daemons: []ports.BrokerDaemonObservation{ | |
| 746 | + | pickerTestLocalObservation(time.Unix(1000, 0), session), | |
| 747 | + | }}) | |
| 748 | + | } | |
| 749 | + | }(worker) | |
| 750 | + | } | |
| 751 | + | wait.Wait() | |
| 752 | + | require.LessOrEqual(t, len(catalogue.retired), pickerCatalogueMaxRetiredRefs) | |
| 753 | + | require.LessOrEqual(t, len(catalogue.retiredOrder), pickerCatalogueMaxRetiredRefs) | |
| 754 | + | } | |
| 755 | + | ||
| 756 | + | // TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility pins the | |
| 757 | + | // authoritative ordering: availability first, then the reachable version | |
| 758 | + | // branch. An unobserved (ProtocolVersion 0) or unreachable daemon is never a | |
| 759 | + | // version mismatch. | |
| 760 | + | func TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility(t *testing.T) { | |
| 761 | + | now := time.Unix(1000, 0) | |
| 762 | + | reachable := func(mutate func(*ports.BrokerDaemonObservation)) ports.BrokerDaemonObservation { | |
| 763 | + | observation := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |
| 764 | + | if mutate != nil { | |
| 765 | + | mutate(&observation) | |
| 766 | + | } | |
| 767 | + | return observation | |
| 768 | + | } | |
| 769 | + | unobserved := func(mutate func(*ports.BrokerDaemonObservation)) ports.BrokerDaemonObservation { | |
| 770 | + | observation := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |
| 771 | + | if mutate != nil { | |
| 772 | + | mutate(&observation) | |
| 773 | + | } | |
| 774 | + | return observation | |
| 775 | + | } | |
| 776 | + | ||
| 777 | + | tests := []struct { | |
| 778 | + | name string | |
| 779 | + | observation ports.BrokerDaemonObservation | |
| 780 | + | fresh bool | |
| 781 | + | wantStatus protocol.PickerLineStatus | |
| 782 | + | wantReason string | |
| 783 | + | }{ | |
| 784 | + | {name: "unobserved unknown is stale and refreshing", observation: unobserved(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing}, | |
| 785 | + | {name: "unreachable is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnreachable }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable}, | |
| 786 | + | {name: "auth failed is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityAuthFailed }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonAuthFailure}, | |
| 787 | + | {name: "invalid response is error", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityInvalidResponse }), wantStatus: protocol.PickerLineStatusError, wantReason: domain.RemoteReasonMalformed}, | |
| 788 | + | {name: "unreachable observed mismatch is down", observation: reachable(func(o *ports.BrokerDaemonObservation) { | |
| 789 | + | o.Availability = domain.RemoteAvailabilityUnreachable | |
| 790 | + | o.ProtocolVersion = protocol.Version + 1 | |
| 791 | + | }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable}, | |
| 792 | + | {name: "reachable mismatch is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.ProtocolVersion = protocol.Version + 1 }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch}, | |
| 793 | + | {name: "reachable compatible fresh is up", observation: reachable(nil), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: ""}, | |
| 794 | + | {name: "reachable compatible stale is stale", observation: reachable(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonCatalogStale}, | |
| 795 | + | {name: "reachable unobserved is stale and refreshing", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityReachable }), fresh: true, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing}, | |
| 796 | + | {name: "legacy incompatible availability is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityIncompatible }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch}, | |
| 797 | + | {name: "reachable checking is up and refreshing", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Checking = true }), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: domain.RemoteReasonRefreshing}, | |
| 798 | + | } | |
| 799 | + | for _, tt := range tests { | |
| 800 | + | t.Run(tt.name, func(t *testing.T) { | |
| 801 | + | require.Equal(t, tt.wantStatus, pickerObservationStatus(tt.observation, tt.fresh)) | |
| 802 | + | require.Equal(t, tt.wantReason, pickerObservationReason(tt.observation, tt.fresh)) | |
| 803 | + | }) | |
| 804 | + | } | |
| 805 | + | } | |
| 806 | + | ||
| 807 | + | // TestPickerCatalogueAvailabilityFirstResolution proves resolution checks | |
| 808 | + | // availability before compatibility, so an unobserved or unreachable host | |
| 809 | + | // refuses as unavailable rather than as a version mismatch. | |
| 810 | + | func TestPickerCatalogueAvailabilityFirstResolution(t *testing.T) { | |
| 811 | + | now := time.Unix(1000, 0) | |
| 812 | + | tests := []struct { | |
| 813 | + | name string | |
| 814 | + | daemon ports.BrokerDaemonObservation | |
| 815 | + | wantErr pickerCatalogueErrorCode | |
| 816 | + | }{ | |
| 817 | + | { | |
| 818 | + | name: "unobserved unknown refuses unavailable", | |
| 819 | + | daemon: pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |
| 820 | + | wantErr: pickerCatalogueUnavailable, | |
| 821 | + | }, | |
| 822 | + | { | |
| 823 | + | name: "unreachable observed mismatch refuses unavailable", | |
| 824 | + | daemon: func() ports.BrokerDaemonObservation { | |
| 825 | + | o := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |
| 826 | + | o.Availability = domain.RemoteAvailabilityUnreachable | |
| 827 | + | o.ProtocolVersion = protocol.Version + 1 | |
| 828 | + | return o | |
| 829 | + | }(), | |
| 830 | + | wantErr: pickerCatalogueUnavailable, | |
| 831 | + | }, | |
| 832 | + | { | |
| 833 | + | name: "auth failed refuses unavailable", | |
| 834 | + | daemon: func() ports.BrokerDaemonObservation { | |
| 835 | + | o := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |
| 836 | + | o.Availability = domain.RemoteAvailabilityAuthFailed | |
| 837 | + | return o | |
| 838 | + | }(), | |
| 839 | + | wantErr: pickerCatalogueUnavailable, | |
| 840 | + | }, | |
| 841 | + | { | |
| 842 | + | name: "invalid response refuses unavailable", | |
| 843 | + | daemon: func() ports.BrokerDaemonObservation { | |
| 844 | + | o := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |
| 845 | + | o.Availability = domain.RemoteAvailabilityInvalidResponse | |
| 846 | + | return o | |
| 847 | + | }(), | |
| 848 | + | wantErr: pickerCatalogueUnavailable, | |
| 849 | + | }, | |
| 850 | + | { | |
| 851 | + | name: "reachable unobserved refuses unavailable", | |
| 852 | + | daemon: func() ports.BrokerDaemonObservation { | |
| 853 | + | o := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |
| 854 | + | o.Availability = domain.RemoteAvailabilityReachable | |
| 855 | + | return o | |
| 856 | + | }(), | |
| 857 | + | wantErr: pickerCatalogueUnavailable, | |
| 858 | + | }, | |
| 859 | + | { | |
| 860 | + | name: "reachable mismatch refuses incompatible", | |
| 861 | + | daemon: func() ports.BrokerDaemonObservation { | |
| 862 | + | o := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |
| 863 | + | o.ProtocolVersion = protocol.Version + 1 | |
| 864 | + | return o | |
| 865 | + | }(), | |
| 866 | + | wantErr: pickerCatalogueIncompatible, | |
| 867 | + | }, | |
| 868 | + | { | |
| 869 | + | name: "legacy incompatible availability refuses incompatible", | |
| 870 | + | daemon: func() ports.BrokerDaemonObservation { | |
| 871 | + | o := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |
| 872 | + | o.Availability = domain.RemoteAvailabilityIncompatible | |
| 873 | + | return o | |
| 874 | + | }(), | |
| 875 | + | wantErr: pickerCatalogueIncompatible, | |
| 876 | + | }, | |
| 877 | + | } | |
| 878 | + | for _, tt := range tests { | |
| 879 | + | t.Run(tt.name, func(t *testing.T) { | |
| 880 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 881 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}})) | |
| 882 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |
| 883 | + | require.True(t, ok) | |
| 884 | + | _, err := catalogue.Resolve(line.Key, pickerTestBase()) | |
| 885 | + | require.Error(t, err) | |
| 886 | + | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | |
| 887 | + | }) | |
| 888 | + | } | |
| 889 | + | } | |
| 890 | + | ||
| 891 | + | // TestPickerCatalogueRejectsInvalidSnapshot pins the projection trust | |
| 892 | + | // boundary: a snapshot that fails its own contract is refused and the previous | |
| 893 | + | // projection is kept intact. | |
| 894 | + | func TestPickerCatalogueRejectsInvalidSnapshot(t *testing.T) { | |
| 895 | + | catalogue, clock := pickerTestCatalogue(t) | |
| 896 | + | valid := pickerTestSnapshot(3, 1, clock.Now(), "alpha") | |
| 897 | + | require.True(t, catalogue.Apply(valid)) | |
| 898 | + | validLines := catalogue.Lines() | |
| 899 | + | require.Equal(t, ports.BrokerRevision(1), catalogue.Revision()) | |
| 900 | + | ||
| 901 | + | invalid := pickerTestSnapshot(3, 2, clock.Now(), "beta") | |
| 902 | + | invalid.Daemons[0].Availability = 0 // out of range: availability is never zero | |
| 903 | + | require.False(t, catalogue.Apply(invalid), "an invalid snapshot is refused") | |
| 904 | + | require.Equal(t, ports.BrokerRevision(1), catalogue.Revision(), "the previous projection is kept") | |
| 905 | + | require.Equal(t, validLines, catalogue.Lines()) | |
| 906 | + | } | |
| 907 | + | ||
| 908 | + | // TestPickerCatalogueUnobservedDaemonProjects proves an unobserved daemon | |
| 909 | + | // (Availability Unknown, ProtocolVersion 0, DisplayOrigin set) still projects a | |
| 910 | + | // section and host row, classified as stale/refreshing rather than as a version | |
| 911 | + | // mismatch. | |
| 912 | + | func TestPickerCatalogueUnobservedDaemonProjects(t *testing.T) { | |
| 913 | + | catalogue, _ := pickerTestCatalogue(t) | |
| 914 | + | unobserved := pickerTestUnobservedObservation(time.Unix(1000, 0), pickerTestSession("alpha", 1, catalogue_Up)) | |
| 915 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}})) | |
| 916 | + | ||
| 917 | + | lines := catalogue.Lines() | |
| 918 | + | require.Equal(t, []string{"local"}, pickerSectionLabels(lines)) | |
| 919 | + | require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines)) | |
| 920 | + | host, ok := pickerLineByLabel(lines, "local") | |
| 921 | + | require.True(t, ok) | |
| 922 | + | require.Equal(t, protocol.PickerLineStatusStale, host.Status) | |
| 923 | + | require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch") | |
| 924 | + | session, ok := pickerLineByLabel(lines, "alpha") | |
| 925 | + | require.True(t, ok) | |
| 926 | + | require.True(t, session.Focusable) | |
| 927 | + | require.Zero(t, session.Actions, "an unobserved host row is inspectable only") | |
| 928 | + | require.True(t, session.Dim) | |
| 929 | + | } | |
| 930 | + | ||
| 931 | + | // TestPickerCatalogueOriginLabelNeverShowsRawEndpoint proves display labels | |
| 932 | + | // never fall back to a raw endpoint. | |
| 933 | + | func TestPickerCatalogueOriginLabelNeverShowsRawEndpoint(t *testing.T) { | |
| 934 | + | raw := pickerTestRemoteObservation("user@arch", 1, 1, time.Unix(1000, 0)) | |
| 935 | + | raw.DisplayOrigin = "" | |
| 936 | + | require.Equal(t, pickerCatalogueOriginFallback, pickerOriginLabel(raw)) | |
| 937 | + | require.NotContains(t, pickerOriginLabel(raw), "user@arch") | |
| 938 | + | ||
| 939 | + | require.Equal(t, "local", pickerOriginLabel(pickerTestUnobservedObservation(time.Unix(1000, 0)))) | |
| 940 | + | require.Equal(t, "user@arch", pickerOriginLabel(pickerTestRemoteObservation("user@arch", 1, 1, time.Unix(1000, 0)))) | |
| 941 | + | } | |
| @@ -0,0 +1,941 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "sync" | |||
| 6 | + | "testing" | |||
| 7 | + | "time" | |||
| 8 | + | ||||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/vev/internal/domain" | |||
| 12 | + | "github.com/bnema/vev/internal/ports" | |||
| 13 | + | "github.com/bnema/vev/internal/protocol" | |||
| 14 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | // Client-owned picker catalogue tests (Plan 001 P5.2b). Every case is | |||
| 18 | + | // deterministic: the fake clock, not wall time, decides freshness, and no case | |||
| 19 | + | // sleeps. | |||
| 20 | + | ||||
| 21 | + | const pickerTestFreshness = 30 * time.Second | |||
| 22 | + | ||||
| 23 | + | func pickerTestPolicy() ports.BrokerPolicy { | |||
| 24 | + | return ports.BrokerPolicy{ | |||
| 25 | + | ProtocolVersion: protocol.Version, | |||
| 26 | + | CatalogSchemaVersion: catalogue.RemoteCatalogSchemaVersion, | |||
| 27 | + | EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned, | |||
| 28 | + | Transport: "quic", | |||
| 29 | + | Trust: "trust", | |||
| 30 | + | Launch: "launch", | |||
| 31 | + | Isolation: "user", | |||
| 32 | + | } | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func pickerTestIncarnation(seed byte) [16]byte { | |||
| 36 | + | var id [16]byte | |||
| 37 | + | id[0] = seed | |||
| 38 | + | return id | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func pickerTestRegistration(endpoint string, seed byte) domain.RemoteRegistration { | |||
| 42 | + | return domain.RemoteRegistration{Endpoint: endpoint, Incarnation: pickerTestIncarnation(seed), Generation: 1} | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func pickerTestLifecycle(seed byte) domain.SessionLifecycleID { return pickerTestIncarnation(seed) } | |||
| 46 | + | ||||
| 47 | + | func pickerTestSession(name string, seed byte, state catalogue.RemoteCatalogSessionState) catalogue.RemoteCatalogSession { | |||
| 48 | + | return catalogue.RemoteCatalogSession{LifecycleID: pickerTestLifecycle(seed), Name: name, State: state} | |||
| 49 | + | } | |||
| 50 | + | ||||
| 51 | + | func pickerTestSessionWithLifecycle(name string, lifecycle domain.SessionLifecycleID) catalogue.RemoteCatalogSession { | |||
| 52 | + | return catalogue.RemoteCatalogSession{LifecycleID: lifecycle, Name: name, State: catalogue_Up} | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func pickerTestLocalObservation(now time.Time, sessions ...catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation { | |||
| 56 | + | return ports.BrokerDaemonObservation{ | |||
| 57 | + | Local: true, | |||
| 58 | + | DisplayOrigin: "local", | |||
| 59 | + | Policy: pickerTestPolicy(), | |||
| 60 | + | Identity: "local-daemon", | |||
| 61 | + | Incarnation: ports.BrokerDaemonIncarnation(pickerTestIncarnation(9)), | |||
| 62 | + | ProtocolVersion: protocol.Version, | |||
| 63 | + | Availability: domain.RemoteAvailabilityReachable, | |||
| 64 | + | LastSuccess: now, | |||
| 65 | + | InventoryKnown: true, | |||
| 66 | + | Sessions: sessions, | |||
| 67 | + | } | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func pickerTestRemoteObservation(endpoint string, registrationSeed, incarnationSeed byte, now time.Time, sessions ...catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation { | |||
| 71 | + | return ports.BrokerDaemonObservation{ | |||
| 72 | + | Endpoint: endpoint, | |||
| 73 | + | DisplayOrigin: endpoint, | |||
| 74 | + | Registration: pickerTestRegistration(endpoint, registrationSeed), | |||
| 75 | + | Policy: pickerTestPolicy(), | |||
| 76 | + | Identity: ports.BrokerDaemonIdentity("identity-" + endpoint), | |||
| 77 | + | Incarnation: ports.BrokerDaemonIncarnation(pickerTestIncarnation(incarnationSeed)), | |||
| 78 | + | ProtocolVersion: protocol.Version, | |||
| 79 | + | Availability: domain.RemoteAvailabilityReachable, | |||
| 80 | + | LastSuccess: now, | |||
| 81 | + | InventoryKnown: true, | |||
| 82 | + | Sessions: sessions, | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func pickerTestUnobservedObservation(now time.Time, sessions ...catalogue.RemoteCatalogSession) ports.BrokerDaemonObservation { | |||
| 87 | + | return ports.BrokerDaemonObservation{ | |||
| 88 | + | Local: true, | |||
| 89 | + | DisplayOrigin: "local", | |||
| 90 | + | Policy: pickerTestPolicy(), | |||
| 91 | + | Availability: domain.RemoteAvailabilityUnknown, | |||
| 92 | + | InventoryKnown: len(sessions) > 0, | |||
| 93 | + | Sessions: sessions, | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func advancePickerClock(clock *supervisorTestClock, delta time.Duration) { | |||
| 98 | + | clock.mu.Lock() | |||
| 99 | + | clock.now = clock.now.Add(delta) | |||
| 100 | + | clock.mu.Unlock() | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | func pickerTestCatalogue(t *testing.T) (*pickerCatalogue, *supervisorTestClock) { | |||
| 104 | + | t.Helper() | |||
| 105 | + | clock := newSupervisorTestClock() | |||
| 106 | + | return newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: pickerTestFreshness}), clock | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func pickerLineByLabel(lines []protocol.PickerLine, label string) (protocol.PickerLine, bool) { | |||
| 110 | + | for _, line := range lines { | |||
| 111 | + | if line.Kind != protocol.PickerLineSection && line.Label == label { | |||
| 112 | + | return line, true | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | return protocol.PickerLine{}, false | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | func pickerSectionLabels(lines []protocol.PickerLine) []string { | |||
| 119 | + | sections := make([]string, 0) | |||
| 120 | + | for _, line := range lines { | |||
| 121 | + | if line.Kind == protocol.PickerLineSection { | |||
| 122 | + | sections = append(sections, line.Label) | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | return sections | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func pickerSessionLabels(lines []protocol.PickerLine) []string { | |||
| 129 | + | labels := make([]string, 0) | |||
| 130 | + | for _, line := range lines { | |||
| 131 | + | if line.Kind == protocol.PickerLineSession { | |||
| 132 | + | labels = append(labels, line.Label) | |||
| 133 | + | } | |||
| 134 | + | } | |||
| 135 | + | return labels | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func pickerTestBase() pickerResolveBase { | |||
| 139 | + | return pickerResolveBase{Connection: ports.BrokerConnectionID{1}, Stream: ports.BrokerStreamID(1)} | |||
| 140 | + | } | |||
| 141 | + | ||||
| 142 | + | func TestPickerCatalogueEmptySnapshot(t *testing.T) { | |||
| 143 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 144 | + | require.False(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 0, Revision: 1}), "an invalid snapshot is never applied") | |||
| 145 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 5, Revision: 1})) | |||
| 146 | + | require.Empty(t, catalogue.Lines()) | |||
| 147 | + | require.Equal(t, ports.BrokerEpoch(5), catalogue.Epoch()) | |||
| 148 | + | require.Equal(t, ports.BrokerRevision(1), catalogue.Revision()) | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestPickerCatalogueProjectionCases(t *testing.T) { | |||
| 152 | + | clock := newSupervisorTestClock() | |||
| 153 | + | now := clock.Now() | |||
| 154 | + | ||||
| 155 | + | tests := []struct { | |||
| 156 | + | name string | |||
| 157 | + | daemons []ports.BrokerDaemonObservation | |||
| 158 | + | wantSections []string | |||
| 159 | + | wantSessions []string | |||
| 160 | + | wantSessionKeys map[string]bool // label -> action available | |||
| 161 | + | wantStatus map[string]protocol.PickerLineStatus | |||
| 162 | + | wantDim map[string]bool | |||
| 163 | + | }{ | |||
| 164 | + | { | |||
| 165 | + | name: "local only", | |||
| 166 | + | daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up))}, | |||
| 167 | + | wantSections: []string{"local"}, | |||
| 168 | + | wantSessions: []string{"alpha", "beta"}, | |||
| 169 | + | wantSessionKeys: map[string]bool{"alpha": true, "beta": true}, | |||
| 170 | + | }, | |||
| 171 | + | { | |||
| 172 | + | name: "unobserved local daemon still projects", | |||
| 173 | + | daemons: []ports.BrokerDaemonObservation{pickerTestUnobservedObservation(now)}, | |||
| 174 | + | wantSections: []string{"local"}, | |||
| 175 | + | wantSessions: []string{}, | |||
| 176 | + | wantStatus: map[string]protocol.PickerLineStatus{"local": protocol.PickerLineStatusStale}, | |||
| 177 | + | }, | |||
| 178 | + | { | |||
| 179 | + | name: "no local daemon entry", | |||
| 180 | + | daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up))}, | |||
| 181 | + | wantSections: []string{"user@arch"}, | |||
| 182 | + | wantSessions: []string{"remote-a"}, | |||
| 183 | + | wantSessionKeys: map[string]bool{"remote-a": true}, | |||
| 184 | + | }, | |||
| 185 | + | { | |||
| 186 | + | name: "stale observation", | |||
| 187 | + | daemons: []ports.BrokerDaemonObservation{ | |||
| 188 | + | func() ports.BrokerDaemonObservation { | |||
| 189 | + | observation := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 190 | + | observation.LastSuccess = now.Add(-2 * pickerTestFreshness) | |||
| 191 | + | return observation | |||
| 192 | + | }(), | |||
| 193 | + | }, | |||
| 194 | + | wantSections: []string{"local"}, | |||
| 195 | + | wantSessions: []string{"alpha"}, | |||
| 196 | + | wantSessionKeys: map[string]bool{"alpha": false}, | |||
| 197 | + | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusUp, "local": protocol.PickerLineStatusStale}, | |||
| 198 | + | wantDim: map[string]bool{"alpha": true}, | |||
| 199 | + | }, | |||
| 200 | + | { | |||
| 201 | + | name: "unavailable host", | |||
| 202 | + | daemons: []ports.BrokerDaemonObservation{ | |||
| 203 | + | func() ports.BrokerDaemonObservation { | |||
| 204 | + | observation := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up)) | |||
| 205 | + | observation.Availability = domain.RemoteAvailabilityUnreachable | |||
| 206 | + | return observation | |||
| 207 | + | }(), | |||
| 208 | + | }, | |||
| 209 | + | wantSections: []string{"user@arch"}, | |||
| 210 | + | wantSessions: []string{"remote-a"}, | |||
| 211 | + | wantSessionKeys: map[string]bool{"remote-a": false}, | |||
| 212 | + | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusDown}, | |||
| 213 | + | wantDim: map[string]bool{"remote-a": true}, | |||
| 214 | + | }, | |||
| 215 | + | { | |||
| 216 | + | name: "incompatible host", | |||
| 217 | + | daemons: []ports.BrokerDaemonObservation{ | |||
| 218 | + | func() ports.BrokerDaemonObservation { | |||
| 219 | + | observation := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 3, catalogue_Up)) | |||
| 220 | + | observation.ProtocolVersion = protocol.Version + 1 | |||
| 221 | + | return observation | |||
| 222 | + | }(), | |||
| 223 | + | }, | |||
| 224 | + | wantSections: []string{"user@arch"}, | |||
| 225 | + | wantSessions: []string{"remote-a"}, | |||
| 226 | + | wantSessionKeys: map[string]bool{"remote-a": false}, | |||
| 227 | + | wantStatus: map[string]protocol.PickerLineStatus{"remote-a": protocol.PickerLineStatusUp, "user@arch": protocol.PickerLineStatusVersion}, | |||
| 228 | + | wantDim: map[string]bool{"remote-a": true}, | |||
| 229 | + | }, | |||
| 230 | + | { | |||
| 231 | + | name: "broken session", | |||
| 232 | + | daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken))}, | |||
| 233 | + | wantSections: []string{"local"}, | |||
| 234 | + | wantSessions: []string{"alpha"}, | |||
| 235 | + | wantSessionKeys: map[string]bool{"alpha": false}, | |||
| 236 | + | wantStatus: map[string]protocol.PickerLineStatus{"alpha": protocol.PickerLineStatusError}, | |||
| 237 | + | wantDim: map[string]bool{"alpha": true}, | |||
| 238 | + | }, | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | for _, tt := range tests { | |||
| 242 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 243 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 244 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 7, Revision: 1, Daemons: tt.daemons})) | |||
| 245 | + | lines := catalogue.Lines() | |||
| 246 | + | require.Equal(t, tt.wantSections, pickerSectionLabels(lines)) | |||
| 247 | + | require.Equal(t, tt.wantSessions, pickerSessionLabels(lines)) | |||
| 248 | + | for label, actionable := range tt.wantSessionKeys { | |||
| 249 | + | line, ok := pickerLineByLabel(lines, label) | |||
| 250 | + | require.True(t, ok, "session %q must be projected", label) | |||
| 251 | + | require.True(t, line.Focusable, "a projected row is always a cursor destination") | |||
| 252 | + | if actionable { | |||
| 253 | + | require.Equal(t, protocol.PickerCanNavigate, line.Actions, "an eligible row admits navigation") | |||
| 254 | + | } else { | |||
| 255 | + | require.Zero(t, line.Actions, "a refused row is inspectable but never committable") | |||
| 256 | + | } | |||
| 257 | + | } | |||
| 258 | + | for label, status := range tt.wantStatus { | |||
| 259 | + | line, ok := pickerLineByLabel(lines, label) | |||
| 260 | + | require.True(t, ok, "row %q must be projected", label) | |||
| 261 | + | require.Equal(t, status, line.Status, "row %q badge", label) | |||
| 262 | + | } | |||
| 263 | + | for label, dim := range tt.wantDim { | |||
| 264 | + | line, ok := pickerLineByLabel(lines, label) | |||
| 265 | + | require.True(t, ok) | |||
| 266 | + | require.Equal(t, dim, line.Dim, "row %q dim", label) | |||
| 267 | + | } | |||
| 268 | + | }) | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func TestPickerCatalogueMonotonicUpdates(t *testing.T) { | |||
| 273 | + | clock := newSupervisorTestClock() | |||
| 274 | + | now := clock.Now() | |||
| 275 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 276 | + | ||||
| 277 | + | newer := ports.BrokerSnapshot{Epoch: 3, Revision: 4, Daemons: []ports.BrokerDaemonObservation{ | |||
| 278 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |||
| 279 | + | }} | |||
| 280 | + | require.True(t, catalogue.Apply(newer)) | |||
| 281 | + | before := catalogue.Lines() | |||
| 282 | + | ||||
| 283 | + | // A lower revision in the same epoch never supersedes and never regresses. | |||
| 284 | + | older := ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ | |||
| 285 | + | pickerTestLocalObservation(now, pickerTestSession("beta", 2, catalogue_Up)), | |||
| 286 | + | }} | |||
| 287 | + | require.False(t, catalogue.Apply(older)) | |||
| 288 | + | require.Equal(t, before, catalogue.Lines()) | |||
| 289 | + | require.Equal(t, ports.BrokerRevision(4), catalogue.Revision()) | |||
| 290 | + | ||||
| 291 | + | // A new epoch is a full resynchronization and replaces every row. | |||
| 292 | + | epoch := ports.BrokerSnapshot{Epoch: 4, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 293 | + | pickerTestLocalObservation(now, pickerTestSession("gamma", 3, catalogue_Up)), | |||
| 294 | + | }} | |||
| 295 | + | require.True(t, catalogue.Apply(epoch)) | |||
| 296 | + | require.Equal(t, []string{"gamma"}, pickerSessionLabels(catalogue.Lines())) | |||
| 297 | + | } | |||
| 298 | + | ||||
| 299 | + | // catalogue_Up/Broken keep the tables above short without importing the | |||
| 300 | + | // catalogue state into every literal. | |||
| 301 | + | const ( | |||
| 302 | + | catalogue_Up = catalogue.RemoteCatalogSessionState("up") | |||
| 303 | + | catalogue_Broken = catalogue.RemoteCatalogSessionState("broken") | |||
| 304 | + | ) | |||
| 305 | + | ||||
| 306 | + | func TestPickerCatalogueSlowObservationKeepsNewerState(t *testing.T) { | |||
| 307 | + | clock := newSupervisorTestClock() | |||
| 308 | + | now := clock.Now() | |||
| 309 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 310 | + | ||||
| 311 | + | fresh := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 312 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{fresh}})) | |||
| 313 | + | ||||
| 314 | + | regressed := pickerTestRemoteObservation("user@arch", 1, 1, now.Add(-time.Minute), pickerTestSession("beta", 2, catalogue_Up)) | |||
| 315 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 3, Daemons: []ports.BrokerDaemonObservation{regressed}}), "the publication supersedes") | |||
| 316 | + | ||||
| 317 | + | require.Equal(t, []string{"alpha"}, pickerSessionLabels(catalogue.Lines()), "an older observed inventory never regresses a row") | |||
| 318 | + | } | |||
| 319 | + | ||||
| 320 | + | func TestPickerCatalogueIdentityReplacement(t *testing.T) { | |||
| 321 | + | clock := newSupervisorTestClock() | |||
| 322 | + | now := clock.Now() | |||
| 323 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 324 | + | ||||
| 325 | + | original := ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 326 | + | pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("alpha", 1, catalogue_Up)), | |||
| 327 | + | }} | |||
| 328 | + | require.True(t, catalogue.Apply(original)) | |||
| 329 | + | oldLine, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |||
| 330 | + | require.True(t, ok) | |||
| 331 | + | oldKey := oldLine.Key | |||
| 332 | + | ||||
| 333 | + | replaced := ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ | |||
| 334 | + | pickerTestRemoteObservation("user@arch", 2, 2, now, pickerTestSession("alpha", 9, catalogue_Up)), | |||
| 335 | + | }} | |||
| 336 | + | require.True(t, catalogue.Apply(replaced)) | |||
| 337 | + | newLine, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |||
| 338 | + | require.True(t, ok) | |||
| 339 | + | require.NotEqual(t, oldKey, newLine.Key, "a replaced identity gets a new opaque key") | |||
| 340 | + | ||||
| 341 | + | // The old key is still recognized and refused as a replacement, not silently | |||
| 342 | + | // re-bound to the new session. | |||
| 343 | + | _, err := catalogue.Resolve(oldKey, pickerTestBase()) | |||
| 344 | + | require.Error(t, err) | |||
| 345 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueReplaced), "same endpoint with a new incarnation is a replacement") | |||
| 346 | + | } | |||
| 347 | + | ||||
| 348 | + | func TestPickerCatalogueStableRowOrder(t *testing.T) { | |||
| 349 | + | clock := newSupervisorTestClock() | |||
| 350 | + | now := clock.Now() | |||
| 351 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 352 | + | ||||
| 353 | + | first := ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 354 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |||
| 355 | + | pickerTestRemoteObservation("host-a", 1, 1, now, pickerTestSession("a-one", 2, catalogue_Up)), | |||
| 356 | + | pickerTestRemoteObservation("host-b", 2, 2, now, pickerTestSession("b-one", 3, catalogue_Up)), | |||
| 357 | + | }} | |||
| 358 | + | require.True(t, catalogue.Apply(first)) | |||
| 359 | + | require.Equal(t, []string{"local", "host-a", "host-b"}, pickerSectionLabels(catalogue.Lines())) | |||
| 360 | + | ||||
| 361 | + | // The publication reorders the remote hosts and changes one unrelated | |||
| 362 | + | // session; the first-seen order never reshuffles. The local daemon stays | |||
| 363 | + | // first, as every valid broker publication requires. | |||
| 364 | + | second := ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ | |||
| 365 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |||
| 366 | + | pickerTestRemoteObservation("host-b", 2, 2, now, pickerTestSession("b-one", 3, catalogue_Up), pickerTestSession("b-two", 4, catalogue_Up)), | |||
| 367 | + | pickerTestRemoteObservation("host-a", 1, 1, now, pickerTestSession("a-one", 2, catalogue_Up)), | |||
| 368 | + | }} | |||
| 369 | + | require.True(t, catalogue.Apply(second)) | |||
| 370 | + | require.Equal(t, []string{"local", "host-a", "host-b"}, pickerSectionLabels(catalogue.Lines())) | |||
| 371 | + | require.Equal(t, []string{"alpha", "a-one", "b-one", "b-two"}, pickerSessionLabels(catalogue.Lines())) | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | func TestPickerCatalogueDeterministicKeys(t *testing.T) { | |||
| 375 | + | clock := newSupervisorTestClock() | |||
| 376 | + | now := clock.Now() | |||
| 377 | + | daemons := []ports.BrokerDaemonObservation{ | |||
| 378 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |||
| 379 | + | pickerTestRemoteObservation("host-a", 1, 1, now, pickerTestSession("a-one", 2, catalogue_Up)), | |||
| 380 | + | } | |||
| 381 | + | first, _ := pickerTestCatalogue(t) | |||
| 382 | + | second, _ := pickerTestCatalogue(t) | |||
| 383 | + | require.True(t, first.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: daemons})) | |||
| 384 | + | require.True(t, second.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: daemons})) | |||
| 385 | + | require.Equal(t, first.Lines(), second.Lines()) | |||
| 386 | + | } | |||
| 387 | + | ||||
| 388 | + | func TestPickerCatalogueSelectionResolution(t *testing.T) { | |||
| 389 | + | clock := newSupervisorTestClock() | |||
| 390 | + | now := clock.Now() | |||
| 391 | + | base := pickerTestBase() | |||
| 392 | + | ||||
| 393 | + | tests := []struct { | |||
| 394 | + | name string | |||
| 395 | + | build func(t *testing.T, catalogue *pickerCatalogue, clock *supervisorTestClock) string | |||
| 396 | + | wantErr pickerCatalogueErrorCode | |||
| 397 | + | wantLocal bool | |||
| 398 | + | wantAdmission ports.BrokerStreamAdmission | |||
| 399 | + | }{ | |||
| 400 | + | { | |||
| 401 | + | name: "local exact attaches to the lifecycle", | |||
| 402 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |||
| 403 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 404 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |||
| 405 | + | }})) | |||
| 406 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |||
| 407 | + | require.True(t, ok) | |||
| 408 | + | return line.Key | |||
| 409 | + | }, | |||
| 410 | + | wantLocal: true, | |||
| 411 | + | wantAdmission: ports.BrokerAdmissionExact, | |||
| 412 | + | }, | |||
| 413 | + | { | |||
| 414 | + | name: "remote exact carries the configured authority", | |||
| 415 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |||
| 416 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 417 | + | pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)), | |||
| 418 | + | }})) | |||
| 419 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a") | |||
| 420 | + | require.True(t, ok) | |||
| 421 | + | return line.Key | |||
| 422 | + | }, | |||
| 423 | + | wantAdmission: ports.BrokerAdmissionExact, | |||
| 424 | + | }, | |||
| 425 | + | { | |||
| 426 | + | name: "gone host", | |||
| 427 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |||
| 428 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 429 | + | pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)), | |||
| 430 | + | }})) | |||
| 431 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a") | |||
| 432 | + | require.True(t, ok) | |||
| 433 | + | key := line.Key | |||
| 434 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2})) | |||
| 435 | + | return key | |||
| 436 | + | }, | |||
| 437 | + | wantErr: pickerCatalogueGone, | |||
| 438 | + | }, | |||
| 439 | + | { | |||
| 440 | + | name: "stale observation", | |||
| 441 | + | build: func(t *testing.T, catalogue *pickerCatalogue, clock *supervisorTestClock) string { | |||
| 442 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 443 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |||
| 444 | + | }})) | |||
| 445 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |||
| 446 | + | require.True(t, ok) | |||
| 447 | + | advancePickerClock(clock, 2*pickerTestFreshness) | |||
| 448 | + | return line.Key | |||
| 449 | + | }, | |||
| 450 | + | wantErr: pickerCatalogueStale, | |||
| 451 | + | }, | |||
| 452 | + | { | |||
| 453 | + | name: "incompatible host", | |||
| 454 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |||
| 455 | + | incompatible := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)) | |||
| 456 | + | incompatible.ProtocolVersion = protocol.Version + 1 | |||
| 457 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{incompatible}})) | |||
| 458 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a") | |||
| 459 | + | require.True(t, ok) | |||
| 460 | + | return line.Key | |||
| 461 | + | }, | |||
| 462 | + | wantErr: pickerCatalogueIncompatible, | |||
| 463 | + | }, | |||
| 464 | + | { | |||
| 465 | + | name: "unavailable host", | |||
| 466 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |||
| 467 | + | unavailable := pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)) | |||
| 468 | + | unavailable.Availability = domain.RemoteAvailabilityUnreachable | |||
| 469 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unavailable}})) | |||
| 470 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a") | |||
| 471 | + | require.True(t, ok) | |||
| 472 | + | return line.Key | |||
| 473 | + | }, | |||
| 474 | + | wantErr: pickerCatalogueUnavailable, | |||
| 475 | + | }, | |||
| 476 | + | { | |||
| 477 | + | name: "epoch change", | |||
| 478 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |||
| 479 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 480 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |||
| 481 | + | }})) | |||
| 482 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |||
| 483 | + | require.True(t, ok) | |||
| 484 | + | key := line.Key | |||
| 485 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 4, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 486 | + | pickerTestLocalObservation(now, pickerTestSession("beta", 2, catalogue_Up)), | |||
| 487 | + | }})) | |||
| 488 | + | return key | |||
| 489 | + | }, | |||
| 490 | + | wantErr: pickerCatalogueUnknown, | |||
| 491 | + | }, | |||
| 492 | + | { | |||
| 493 | + | name: "session lifecycle replaced", | |||
| 494 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |||
| 495 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 496 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |||
| 497 | + | }})) | |||
| 498 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |||
| 499 | + | require.True(t, ok) | |||
| 500 | + | key := line.Key | |||
| 501 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ | |||
| 502 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 5, catalogue_Up)), | |||
| 503 | + | }})) | |||
| 504 | + | return key | |||
| 505 | + | }, | |||
| 506 | + | wantErr: pickerCatalogueReplaced, | |||
| 507 | + | }, | |||
| 508 | + | { | |||
| 509 | + | name: "session gone", | |||
| 510 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |||
| 511 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 512 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up), pickerTestSession("beta", 2, catalogue_Up)), | |||
| 513 | + | }})) | |||
| 514 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |||
| 515 | + | require.True(t, ok) | |||
| 516 | + | key := line.Key | |||
| 517 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{ | |||
| 518 | + | pickerTestLocalObservation(now, pickerTestSession("beta", 2, catalogue_Up)), | |||
| 519 | + | }})) | |||
| 520 | + | return key | |||
| 521 | + | }, | |||
| 522 | + | wantErr: pickerCatalogueGone, | |||
| 523 | + | }, | |||
| 524 | + | { | |||
| 525 | + | name: "broken session", | |||
| 526 | + | build: func(t *testing.T, catalogue *pickerCatalogue, _ *supervisorTestClock) string { | |||
| 527 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 528 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Broken)), | |||
| 529 | + | }})) | |||
| 530 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |||
| 531 | + | require.True(t, ok) | |||
| 532 | + | return line.Key | |||
| 533 | + | }, | |||
| 534 | + | wantErr: pickerCatalogueUnavailable, | |||
| 535 | + | }, | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | for _, tt := range tests { | |||
| 539 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 540 | + | catalogue, clock := pickerTestCatalogue(t) | |||
| 541 | + | key := tt.build(t, catalogue, clock) | |||
| 542 | + | request, err := catalogue.Resolve(key, base) | |||
| 543 | + | if tt.wantErr != 0 { | |||
| 544 | + | require.Error(t, err) | |||
| 545 | + | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | |||
| 546 | + | return | |||
| 547 | + | } | |||
| 548 | + | require.NoError(t, err) | |||
| 549 | + | require.NoError(t, request.Validate(), "a resolved request always validates") | |||
| 550 | + | require.Equal(t, ports.BrokerStreamAttachment, request.Purpose) | |||
| 551 | + | require.Equal(t, tt.wantAdmission, request.Admission) | |||
| 552 | + | require.Equal(t, tt.wantLocal, request.Local) | |||
| 553 | + | require.Equal(t, ports.BrokerEpoch(3), request.Epoch) | |||
| 554 | + | require.Equal(t, base.Connection, request.Connection) | |||
| 555 | + | require.Equal(t, base.Stream, request.Stream) | |||
| 556 | + | }) | |||
| 557 | + | } | |||
| 558 | + | } | |||
| 559 | + | ||||
| 560 | + | func TestPickerCatalogueRemoteSelectionCarriesExactIdentity(t *testing.T) { | |||
| 561 | + | clock := newSupervisorTestClock() | |||
| 562 | + | now := clock.Now() | |||
| 563 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 564 | + | registration := pickerTestRegistration("user@arch", 1) | |||
| 565 | + | lifecycle := pickerTestLifecycle(2) | |||
| 566 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 567 | + | pickerTestRemoteObservation("user@arch", 1, 1, now, pickerTestSession("remote-a", 2, catalogue_Up)), | |||
| 568 | + | }})) | |||
| 569 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "remote-a") | |||
| 570 | + | require.True(t, ok) | |||
| 571 | + | ||||
| 572 | + | request, err := catalogue.Resolve(line.Key, pickerTestBase()) | |||
| 573 | + | require.NoError(t, err) | |||
| 574 | + | require.Equal(t, "user@arch", request.Endpoint) | |||
| 575 | + | require.Equal(t, registration, request.Registration) | |||
| 576 | + | require.Equal(t, protocol.ExactSessionTarget{LifecycleID: lifecycle, SessionName: "remote-a"}, request.Target) | |||
| 577 | + | require.Equal(t, pickerTestPolicy(), request.Policy) | |||
| 578 | + | } | |||
| 579 | + | ||||
| 580 | + | func TestPickerCatalogueCreationResolution(t *testing.T) { | |||
| 581 | + | clock := newSupervisorTestClock() | |||
| 582 | + | now := clock.Now() | |||
| 583 | + | base := pickerTestBase() | |||
| 584 | + | ||||
| 585 | + | tests := []struct { | |||
| 586 | + | name string | |||
| 587 | + | local bool | |||
| 588 | + | endpoint string | |||
| 589 | + | kind pickerSelectionKind | |||
| 590 | + | sessionName string | |||
| 591 | + | snapshot func() ports.BrokerSnapshot | |||
| 592 | + | wantErr pickerCatalogueErrorCode | |||
| 593 | + | wantAdmission ports.BrokerStreamAdmission | |||
| 594 | + | }{ | |||
| 595 | + | { | |||
| 596 | + | name: "named creation on the local daemon", | |||
| 597 | + | local: true, | |||
| 598 | + | kind: pickerSelectionCreateNamed, | |||
| 599 | + | sessionName: "fresh", | |||
| 600 | + | snapshot: func() ports.BrokerSnapshot { | |||
| 601 | + | return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now)}} | |||
| 602 | + | }, | |||
| 603 | + | wantAdmission: ports.BrokerAdmissionCreateNamed, | |||
| 604 | + | }, | |||
| 605 | + | { | |||
| 606 | + | name: "ephemeral creation on a remote daemon", | |||
| 607 | + | endpoint: "user@arch", | |||
| 608 | + | kind: pickerSelectionCreateEphemeral, | |||
| 609 | + | snapshot: func() ports.BrokerSnapshot { | |||
| 610 | + | return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestRemoteObservation("user@arch", 1, 1, now)}} | |||
| 611 | + | }, | |||
| 612 | + | wantAdmission: ports.BrokerAdmissionCreateEphemeral, | |||
| 613 | + | }, | |||
| 614 | + | { | |||
| 615 | + | name: "named creation without a name", | |||
| 616 | + | local: true, | |||
| 617 | + | kind: pickerSelectionCreateNamed, | |||
| 618 | + | snapshot: func() ports.BrokerSnapshot { | |||
| 619 | + | return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{pickerTestLocalObservation(now)}} | |||
| 620 | + | }, | |||
| 621 | + | wantErr: pickerCatalogueInvalidName, | |||
| 622 | + | }, | |||
| 623 | + | { | |||
| 624 | + | name: "creation on a missing remote daemon", | |||
| 625 | + | endpoint: "user@arch", | |||
| 626 | + | kind: pickerSelectionCreateEphemeral, | |||
| 627 | + | snapshot: func() ports.BrokerSnapshot { return ports.BrokerSnapshot{Epoch: 3, Revision: 1} }, | |||
| 628 | + | wantErr: pickerCatalogueGone, | |||
| 629 | + | }, | |||
| 630 | + | { | |||
| 631 | + | name: "creation on an incompatible daemon", | |||
| 632 | + | local: true, | |||
| 633 | + | kind: pickerSelectionCreateEphemeral, | |||
| 634 | + | snapshot: func() ports.BrokerSnapshot { | |||
| 635 | + | observation := pickerTestLocalObservation(now) | |||
| 636 | + | observation.ProtocolVersion = protocol.Version + 1 | |||
| 637 | + | return ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{observation}} | |||
| 638 | + | }, | |||
| 639 | + | wantErr: pickerCatalogueIncompatible, | |||
| 640 | + | }, | |||
| 641 | + | } | |||
| 642 | + | ||||
| 643 | + | for _, tt := range tests { | |||
| 644 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 645 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 646 | + | require.True(t, catalogue.Apply(tt.snapshot())) | |||
| 647 | + | request, err := catalogue.ResolveCreation(tt.local, tt.endpoint, tt.kind, tt.sessionName, base) | |||
| 648 | + | if tt.wantErr != 0 { | |||
| 649 | + | require.Error(t, err) | |||
| 650 | + | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | |||
| 651 | + | return | |||
| 652 | + | } | |||
| 653 | + | require.NoError(t, err) | |||
| 654 | + | require.NoError(t, request.Validate()) | |||
| 655 | + | require.Equal(t, tt.wantAdmission, request.Admission) | |||
| 656 | + | }) | |||
| 657 | + | } | |||
| 658 | + | } | |||
| 659 | + | ||||
| 660 | + | func TestPickerCatalogueUnknownKeyRefused(t *testing.T) { | |||
| 661 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 662 | + | _, err := catalogue.Resolve("missing", pickerTestBase()) | |||
| 663 | + | require.Error(t, err) | |||
| 664 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnknown)) | |||
| 665 | + | } | |||
| 666 | + | ||||
| 667 | + | // TestPickerCatalogueEpochFenceOnCapturedRef pins the selection revision: a | |||
| 668 | + | // driver that captured a row's exact identity at one broker epoch may not | |||
| 669 | + | // resolve it after the broker process was replaced, even when the endpoint and | |||
| 670 | + | // session lifecycle happen to look the same. | |||
| 671 | + | func TestPickerCatalogueEpochFenceOnCapturedRef(t *testing.T) { | |||
| 672 | + | clock := newSupervisorTestClock() | |||
| 673 | + | now := clock.Now() | |||
| 674 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 675 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 676 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |||
| 677 | + | }})) | |||
| 678 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |||
| 679 | + | require.True(t, ok) | |||
| 680 | + | ref, ok := catalogue.Ref(line.Key) | |||
| 681 | + | require.True(t, ok) | |||
| 682 | + | ||||
| 683 | + | // A new broker epoch resynchronizes the catalogue; the captured identity is | |||
| 684 | + | // fenced even though its key is recomputed identically. | |||
| 685 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 4, Revision: 1, Daemons: []ports.BrokerDaemonObservation{ | |||
| 686 | + | pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |||
| 687 | + | }})) | |||
| 688 | + | _, err := catalogue.ResolveRef(ref, pickerTestBase()) | |||
| 689 | + | require.Error(t, err) | |||
| 690 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueEpochStale)) | |||
| 691 | + | } | |||
| 692 | + | ||||
| 693 | + | func TestPickerCatalogueRetiredRefFenceIsBounded(t *testing.T) { | |||
| 694 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 695 | + | base := pickerTestBase() | |||
| 696 | + | const inserted = pickerCatalogueMaxRetiredRefs + 8 | |||
| 697 | + | keys := make([]string, 0, inserted) | |||
| 698 | + | for i := range inserted { | |||
| 699 | + | name := fmt.Sprintf("s-%d", i) | |||
| 700 | + | session := pickerTestSession(name, byte(i%200+1), catalogue_Up) | |||
| 701 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: ports.BrokerRevision(i + 1), Daemons: []ports.BrokerDaemonObservation{ | |||
| 702 | + | pickerTestLocalObservation(time.Unix(1000, 0), session), | |||
| 703 | + | }})) | |||
| 704 | + | line, ok := pickerLineByLabel(catalogue.Lines(), name) | |||
| 705 | + | require.True(t, ok) | |||
| 706 | + | keys = append(keys, line.Key) | |||
| 707 | + | } | |||
| 708 | + | // The fence holds the exact bound: only the newest retired keys survive. | |||
| 709 | + | require.Len(t, catalogue.retired, pickerCatalogueMaxRetiredRefs) | |||
| 710 | + | require.Len(t, catalogue.retiredOrder, pickerCatalogueMaxRetiredRefs) | |||
| 711 | + | ||||
| 712 | + | // A retained retired key still classifies as gone: its lifecycle left the | |||
| 713 | + | // projection and no same-name replacement exists. | |||
| 714 | + | retainedKey := keys[len(keys)-2] | |||
| 715 | + | _, err := catalogue.Resolve(retainedKey, base) | |||
| 716 | + | require.Error(t, err) | |||
| 717 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueGone), "a retained retired key must classify as gone: %v", err) | |||
| 718 | + | ||||
| 719 | + | // The oldest key was evicted from the bounded fence and is now unknown. | |||
| 720 | + | evictedKey := keys[0] | |||
| 721 | + | _, err = catalogue.Resolve(evictedKey, base) | |||
| 722 | + | require.Error(t, err) | |||
| 723 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueUnknown), "an evicted retired key must classify as unknown: %v", err) | |||
| 724 | + | } | |||
| 725 | + | ||||
| 726 | + | // TestPickerCatalogueRetiredRefFenceConcurrentApply exercises the retired-ref | |||
| 727 | + | // fence under -race: many goroutines fold distinct publications concurrently | |||
| 728 | + | // and the fence may never exceed its bound. | |||
| 729 | + | func TestPickerCatalogueRetiredRefFenceConcurrentApply(t *testing.T) { | |||
| 730 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 731 | + | const ( | |||
| 732 | + | workers = 8 | |||
| 733 | + | perWorker = 64 | |||
| 734 | + | ) | |||
| 735 | + | var wait sync.WaitGroup | |||
| 736 | + | for worker := range workers { | |||
| 737 | + | wait.Add(1) | |||
| 738 | + | go func(worker int) { | |||
| 739 | + | defer wait.Done() | |||
| 740 | + | for i := range perWorker { | |||
| 741 | + | var lifecycle domain.SessionLifecycleID | |||
| 742 | + | lifecycle[0] = byte(i + 1) | |||
| 743 | + | lifecycle[1] = byte(worker + 1) | |||
| 744 | + | session := pickerTestSessionWithLifecycle(fmt.Sprintf("w%d-%d", worker, i), lifecycle) | |||
| 745 | + | catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: ports.BrokerRevision(worker*perWorker + i + 1), Daemons: []ports.BrokerDaemonObservation{ | |||
| 746 | + | pickerTestLocalObservation(time.Unix(1000, 0), session), | |||
| 747 | + | }}) | |||
| 748 | + | } | |||
| 749 | + | }(worker) | |||
| 750 | + | } | |||
| 751 | + | wait.Wait() | |||
| 752 | + | require.LessOrEqual(t, len(catalogue.retired), pickerCatalogueMaxRetiredRefs) | |||
| 753 | + | require.LessOrEqual(t, len(catalogue.retiredOrder), pickerCatalogueMaxRetiredRefs) | |||
| 754 | + | } | |||
| 755 | + | ||||
| 756 | + | // TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility pins the | |||
| 757 | + | // authoritative ordering: availability first, then the reachable version | |||
| 758 | + | // branch. An unobserved (ProtocolVersion 0) or unreachable daemon is never a | |||
| 759 | + | // version mismatch. | |||
| 760 | + | func TestPickerCatalogueAvailabilityClassifiedBeforeCompatibility(t *testing.T) { | |||
| 761 | + | now := time.Unix(1000, 0) | |||
| 762 | + | reachable := func(mutate func(*ports.BrokerDaemonObservation)) ports.BrokerDaemonObservation { | |||
| 763 | + | observation := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 764 | + | if mutate != nil { | |||
| 765 | + | mutate(&observation) | |||
| 766 | + | } | |||
| 767 | + | return observation | |||
| 768 | + | } | |||
| 769 | + | unobserved := func(mutate func(*ports.BrokerDaemonObservation)) ports.BrokerDaemonObservation { | |||
| 770 | + | observation := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 771 | + | if mutate != nil { | |||
| 772 | + | mutate(&observation) | |||
| 773 | + | } | |||
| 774 | + | return observation | |||
| 775 | + | } | |||
| 776 | + | ||||
| 777 | + | tests := []struct { | |||
| 778 | + | name string | |||
| 779 | + | observation ports.BrokerDaemonObservation | |||
| 780 | + | fresh bool | |||
| 781 | + | wantStatus protocol.PickerLineStatus | |||
| 782 | + | wantReason string | |||
| 783 | + | }{ | |||
| 784 | + | {name: "unobserved unknown is stale and refreshing", observation: unobserved(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing}, | |||
| 785 | + | {name: "unreachable is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityUnreachable }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable}, | |||
| 786 | + | {name: "auth failed is down", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityAuthFailed }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonAuthFailure}, | |||
| 787 | + | {name: "invalid response is error", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityInvalidResponse }), wantStatus: protocol.PickerLineStatusError, wantReason: domain.RemoteReasonMalformed}, | |||
| 788 | + | {name: "unreachable observed mismatch is down", observation: reachable(func(o *ports.BrokerDaemonObservation) { | |||
| 789 | + | o.Availability = domain.RemoteAvailabilityUnreachable | |||
| 790 | + | o.ProtocolVersion = protocol.Version + 1 | |||
| 791 | + | }), wantStatus: protocol.PickerLineStatusDown, wantReason: domain.RemoteReasonHostUnreachable}, | |||
| 792 | + | {name: "reachable mismatch is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.ProtocolVersion = protocol.Version + 1 }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch}, | |||
| 793 | + | {name: "reachable compatible fresh is up", observation: reachable(nil), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: ""}, | |||
| 794 | + | {name: "reachable compatible stale is stale", observation: reachable(nil), fresh: false, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonCatalogStale}, | |||
| 795 | + | {name: "reachable unobserved is stale and refreshing", observation: unobserved(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityReachable }), fresh: true, wantStatus: protocol.PickerLineStatusStale, wantReason: domain.RemoteReasonRefreshing}, | |||
| 796 | + | {name: "legacy incompatible availability is version", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Availability = domain.RemoteAvailabilityIncompatible }), fresh: true, wantStatus: protocol.PickerLineStatusVersion, wantReason: domain.RemoteReasonVersionMismatch}, | |||
| 797 | + | {name: "reachable checking is up and refreshing", observation: reachable(func(o *ports.BrokerDaemonObservation) { o.Checking = true }), fresh: true, wantStatus: protocol.PickerLineStatusUp, wantReason: domain.RemoteReasonRefreshing}, | |||
| 798 | + | } | |||
| 799 | + | for _, tt := range tests { | |||
| 800 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 801 | + | require.Equal(t, tt.wantStatus, pickerObservationStatus(tt.observation, tt.fresh)) | |||
| 802 | + | require.Equal(t, tt.wantReason, pickerObservationReason(tt.observation, tt.fresh)) | |||
| 803 | + | }) | |||
| 804 | + | } | |||
| 805 | + | } | |||
| 806 | + | ||||
| 807 | + | // TestPickerCatalogueAvailabilityFirstResolution proves resolution checks | |||
| 808 | + | // availability before compatibility, so an unobserved or unreachable host | |||
| 809 | + | // refuses as unavailable rather than as a version mismatch. | |||
| 810 | + | func TestPickerCatalogueAvailabilityFirstResolution(t *testing.T) { | |||
| 811 | + | now := time.Unix(1000, 0) | |||
| 812 | + | tests := []struct { | |||
| 813 | + | name string | |||
| 814 | + | daemon ports.BrokerDaemonObservation | |||
| 815 | + | wantErr pickerCatalogueErrorCode | |||
| 816 | + | }{ | |||
| 817 | + | { | |||
| 818 | + | name: "unobserved unknown refuses unavailable", | |||
| 819 | + | daemon: pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up)), | |||
| 820 | + | wantErr: pickerCatalogueUnavailable, | |||
| 821 | + | }, | |||
| 822 | + | { | |||
| 823 | + | name: "unreachable observed mismatch refuses unavailable", | |||
| 824 | + | daemon: func() ports.BrokerDaemonObservation { | |||
| 825 | + | o := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 826 | + | o.Availability = domain.RemoteAvailabilityUnreachable | |||
| 827 | + | o.ProtocolVersion = protocol.Version + 1 | |||
| 828 | + | return o | |||
| 829 | + | }(), | |||
| 830 | + | wantErr: pickerCatalogueUnavailable, | |||
| 831 | + | }, | |||
| 832 | + | { | |||
| 833 | + | name: "auth failed refuses unavailable", | |||
| 834 | + | daemon: func() ports.BrokerDaemonObservation { | |||
| 835 | + | o := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 836 | + | o.Availability = domain.RemoteAvailabilityAuthFailed | |||
| 837 | + | return o | |||
| 838 | + | }(), | |||
| 839 | + | wantErr: pickerCatalogueUnavailable, | |||
| 840 | + | }, | |||
| 841 | + | { | |||
| 842 | + | name: "invalid response refuses unavailable", | |||
| 843 | + | daemon: func() ports.BrokerDaemonObservation { | |||
| 844 | + | o := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 845 | + | o.Availability = domain.RemoteAvailabilityInvalidResponse | |||
| 846 | + | return o | |||
| 847 | + | }(), | |||
| 848 | + | wantErr: pickerCatalogueUnavailable, | |||
| 849 | + | }, | |||
| 850 | + | { | |||
| 851 | + | name: "reachable unobserved refuses unavailable", | |||
| 852 | + | daemon: func() ports.BrokerDaemonObservation { | |||
| 853 | + | o := pickerTestUnobservedObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 854 | + | o.Availability = domain.RemoteAvailabilityReachable | |||
| 855 | + | return o | |||
| 856 | + | }(), | |||
| 857 | + | wantErr: pickerCatalogueUnavailable, | |||
| 858 | + | }, | |||
| 859 | + | { | |||
| 860 | + | name: "reachable mismatch refuses incompatible", | |||
| 861 | + | daemon: func() ports.BrokerDaemonObservation { | |||
| 862 | + | o := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 863 | + | o.ProtocolVersion = protocol.Version + 1 | |||
| 864 | + | return o | |||
| 865 | + | }(), | |||
| 866 | + | wantErr: pickerCatalogueIncompatible, | |||
| 867 | + | }, | |||
| 868 | + | { | |||
| 869 | + | name: "legacy incompatible availability refuses incompatible", | |||
| 870 | + | daemon: func() ports.BrokerDaemonObservation { | |||
| 871 | + | o := pickerTestLocalObservation(now, pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 872 | + | o.Availability = domain.RemoteAvailabilityIncompatible | |||
| 873 | + | return o | |||
| 874 | + | }(), | |||
| 875 | + | wantErr: pickerCatalogueIncompatible, | |||
| 876 | + | }, | |||
| 877 | + | } | |||
| 878 | + | for _, tt := range tests { | |||
| 879 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 880 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 881 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{tt.daemon}})) | |||
| 882 | + | line, ok := pickerLineByLabel(catalogue.Lines(), "alpha") | |||
| 883 | + | require.True(t, ok) | |||
| 884 | + | _, err := catalogue.Resolve(line.Key, pickerTestBase()) | |||
| 885 | + | require.Error(t, err) | |||
| 886 | + | require.True(t, pickerCatalogueErrorIs(err, tt.wantErr), "want %v, got %v", tt.wantErr, err) | |||
| 887 | + | }) | |||
| 888 | + | } | |||
| 889 | + | } | |||
| 890 | + | ||||
| 891 | + | // TestPickerCatalogueRejectsInvalidSnapshot pins the projection trust | |||
| 892 | + | // boundary: a snapshot that fails its own contract is refused and the previous | |||
| 893 | + | // projection is kept intact. | |||
| 894 | + | func TestPickerCatalogueRejectsInvalidSnapshot(t *testing.T) { | |||
| 895 | + | catalogue, clock := pickerTestCatalogue(t) | |||
| 896 | + | valid := pickerTestSnapshot(3, 1, clock.Now(), "alpha") | |||
| 897 | + | require.True(t, catalogue.Apply(valid)) | |||
| 898 | + | validLines := catalogue.Lines() | |||
| 899 | + | require.Equal(t, ports.BrokerRevision(1), catalogue.Revision()) | |||
| 900 | + | ||||
| 901 | + | invalid := pickerTestSnapshot(3, 2, clock.Now(), "beta") | |||
| 902 | + | invalid.Daemons[0].Availability = 0 // out of range: availability is never zero | |||
| 903 | + | require.False(t, catalogue.Apply(invalid), "an invalid snapshot is refused") | |||
| 904 | + | require.Equal(t, ports.BrokerRevision(1), catalogue.Revision(), "the previous projection is kept") | |||
| 905 | + | require.Equal(t, validLines, catalogue.Lines()) | |||
| 906 | + | } | |||
| 907 | + | ||||
| 908 | + | // TestPickerCatalogueUnobservedDaemonProjects proves an unobserved daemon | |||
| 909 | + | // (Availability Unknown, ProtocolVersion 0, DisplayOrigin set) still projects a | |||
| 910 | + | // section and host row, classified as stale/refreshing rather than as a version | |||
| 911 | + | // mismatch. | |||
| 912 | + | func TestPickerCatalogueUnobservedDaemonProjects(t *testing.T) { | |||
| 913 | + | catalogue, _ := pickerTestCatalogue(t) | |||
| 914 | + | unobserved := pickerTestUnobservedObservation(time.Unix(1000, 0), pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 915 | + | require.True(t, catalogue.Apply(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}})) | |||
| 916 | + | ||||
| 917 | + | lines := catalogue.Lines() | |||
| 918 | + | require.Equal(t, []string{"local"}, pickerSectionLabels(lines)) | |||
| 919 | + | require.Equal(t, []string{"alpha"}, pickerSessionLabels(lines)) | |||
| 920 | + | host, ok := pickerLineByLabel(lines, "local") | |||
| 921 | + | require.True(t, ok) | |||
| 922 | + | require.Equal(t, protocol.PickerLineStatusStale, host.Status) | |||
| 923 | + | require.Equal(t, domain.RemoteReasonRefreshing, host.StatusDetail, "an unobserved daemon refreshes, it is not a version mismatch") | |||
| 924 | + | session, ok := pickerLineByLabel(lines, "alpha") | |||
| 925 | + | require.True(t, ok) | |||
| 926 | + | require.True(t, session.Focusable) | |||
| 927 | + | require.Zero(t, session.Actions, "an unobserved host row is inspectable only") | |||
| 928 | + | require.True(t, session.Dim) | |||
| 929 | + | } | |||
| 930 | + | ||||
| 931 | + | // TestPickerCatalogueOriginLabelNeverShowsRawEndpoint proves display labels | |||
| 932 | + | // never fall back to a raw endpoint. | |||
| 933 | + | func TestPickerCatalogueOriginLabelNeverShowsRawEndpoint(t *testing.T) { | |||
| 934 | + | raw := pickerTestRemoteObservation("user@arch", 1, 1, time.Unix(1000, 0)) | |||
| 935 | + | raw.DisplayOrigin = "" | |||
| 936 | + | require.Equal(t, pickerCatalogueOriginFallback, pickerOriginLabel(raw)) | |||
| 937 | + | require.NotContains(t, pickerOriginLabel(raw), "user@arch") | |||
| 938 | + | ||||
| 939 | + | require.Equal(t, "local", pickerOriginLabel(pickerTestUnobservedObservation(time.Unix(1000, 0)))) | |||
| 940 | + | require.Equal(t, "user@arch", pickerOriginLabel(pickerTestRemoteObservation("user@arch", 1, 1, time.Unix(1000, 0)))) | |||
| 941 | + | } | |||
A internal/usecase/client/picker_controller.go +444 -0
| @@ -0,0 +1,444 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "sync" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/vev/internal/domain" | |
| 9 | + | "github.com/bnema/vev/internal/ports" | |
| 10 | + | "github.com/bnema/vev/internal/protocol" | |
| 11 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | // Client-owned picker controller (Plan 001 P5.2b, offline and unactivated). | |
| 15 | + | // | |
| 16 | + | // This file is the narrow seam between the autonomous supervisor and the | |
| 17 | + | // client-owned catalogue. The supervisor owns raw mode, the single terminal | |
| 18 | + | // input lifetime, and the broker service; the controller owns the catalogue | |
| 19 | + | // projection, the presentation model (reusing the picker package's cursor, | |
| 20 | + | // search, sort, and rendering machinery), and the input decisions. | |
| 21 | + | // | |
| 22 | + | // Two invariants make the picker autonomous: | |
| 23 | + | // | |
| 24 | + | // - Zero session bytes. The controller has no session, stream, or PTY | |
| 25 | + | // reference. A consumed terminal read is decoded into picker events and | |
| 26 | + | // applied to the model; nothing is ever written toward a daemon. A | |
| 27 | + | // selection resolves to a ports.BrokerOpenStreamRequest value and stops | |
| 28 | + | // there. | |
| 29 | + | // - No serving-daemon catalogue. Rows come from broker snapshots through | |
| 30 | + | // pickerCatalogue, so navigation needs no serving attachment. | |
| 31 | + | // | |
| 32 | + | // input ownership reuses the existing pickerConsumer, so the supervisor's one | |
| 33 | + | // terminal input lifetime feeds exactly one owner and the picker takes | |
| 34 | + | // precedence: while the controller owns input every decoded batch is consumed | |
| 35 | + | // and there is no fallback to a session pipeline. | |
| 36 | + | ||
| 37 | + | // pickerHost is the supervisor's view of the client-owned picker. The | |
| 38 | + | // supervisor applies each broker publication and hands it every terminal read | |
| 39 | + | // from its single input lifetime; the picker decides ownership. | |
| 40 | + | type pickerHost interface { | |
| 41 | + | pickerInputConsumer | |
| 42 | + | // ApplySnapshot folds one broker publication into the catalogue and | |
| 43 | + | // refreshes the presentation immediately. | |
| 44 | + | ApplySnapshot(ports.BrokerSnapshot) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // pickerInputConsumer is the supervisor's narrow input seam. ConsumeTerminalRead | |
| 48 | + | // returns true when the picker owned and consumed the read; false leaves the | |
| 49 | + | // read to the supervisor's ordinary (P5.1a) handling. | |
| 50 | + | type pickerInputConsumer interface { | |
| 51 | + | ConsumeTerminalRead(data []byte) bool | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | const ( | |
| 55 | + | // pickerGeneration identifies the single client-owned picker generation. | |
| 56 | + | // It never changes while the controller lives; the presentation epoch is | |
| 57 | + | // the broker epoch, not this value. | |
| 58 | + | pickerGeneration = uint64(1) | |
| 59 | + | // pickerNoticeLifetime bounds how long one catalogue notice is displayed. | |
| 60 | + | pickerNoticeLifetime = 4 * time.Second | |
| 61 | + | ) | |
| 62 | + | ||
| 63 | + | // pickerController owns one client-owned picker: catalogue, model, renderer, | |
| 64 | + | // bounded notices, and input ownership. It is safe for concurrent use, so the | |
| 65 | + | // supervisor's input lifetime and its run path may both reach it. | |
| 66 | + | type pickerController struct { | |
| 67 | + | clock ports.Clock | |
| 68 | + | catalogue *pickerCatalogue | |
| 69 | + | ||
| 70 | + | mu sync.Mutex | |
| 71 | + | loop *pickerLoop | |
| 72 | + | consumer pickerConsumer | |
| 73 | + | renderer *pickerRenderer | |
| 74 | + | notices ui.ToastManager | |
| 75 | + | interaction uint64 | |
| 76 | + | ownsInput bool | |
| 77 | + | lastOp pickerOp | |
| 78 | + | // lastCommitKey is the catalogue key captured with the pending commit | |
| 79 | + | // decision, so TakeOp can hand a driver the exact committed row. | |
| 80 | + | lastCommitKey string | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // newPickerController builds a picker over an empty catalogue. The picker owns | |
| 84 | + | // input from the start: P5.2b presents it for the whole picker presentation and | |
| 85 | + | // has no session pipeline to defer to. | |
| 86 | + | func newPickerController(clock ports.Clock, freshness time.Duration) *pickerController { | |
| 87 | + | if supervisorNil(clock) { | |
| 88 | + | clock = systemClock{} | |
| 89 | + | } | |
| 90 | + | controller := &pickerController{ | |
| 91 | + | clock: clock, | |
| 92 | + | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | |
| 93 | + | renderer: newPickerRenderer(), | |
| 94 | + | ownsInput: true, | |
| 95 | + | } | |
| 96 | + | controller.consumer.setOwned(pickerGeneration, pickerGeneration) | |
| 97 | + | return controller | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // Catalogue exposes the projection for a driver that needs to resolve a | |
| 101 | + | // selection or inspect the applied revision. | |
| 102 | + | func (p *pickerController) Catalogue() *pickerCatalogue { | |
| 103 | + | if p == nil { | |
| 104 | + | return nil | |
| 105 | + | } | |
| 106 | + | return p.catalogue | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | // SetOwnsInput toggles input ownership. It is the future attach slice's release | |
| 110 | + | // boundary; P5.2b keeps it owned for the whole run. | |
| 111 | + | func (p *pickerController) SetOwnsInput(owns bool) { | |
| 112 | + | if p == nil { | |
| 113 | + | return | |
| 114 | + | } | |
| 115 | + | p.mu.Lock() | |
| 116 | + | defer p.mu.Unlock() | |
| 117 | + | if owns == p.ownsInput { | |
| 118 | + | return | |
| 119 | + | } | |
| 120 | + | p.ownsInput = owns | |
| 121 | + | if owns { | |
| 122 | + | p.consumer.setOwned(pickerGeneration, pickerGeneration) | |
| 123 | + | return | |
| 124 | + | } | |
| 125 | + | p.consumer.clear() | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // ApplySnapshot folds one broker publication into the catalogue and rebuilds | |
| 129 | + | // the presentation synchronously, so the next Render reflects it immediately. | |
| 130 | + | func (p *pickerController) ApplySnapshot(snapshot ports.BrokerSnapshot) { | |
| 131 | + | if p == nil || p.catalogue == nil { | |
| 132 | + | return | |
| 133 | + | } | |
| 134 | + | if !p.catalogue.Apply(snapshot) { | |
| 135 | + | // A publication that fails the broker snapshot contract is rejected as | |
| 136 | + | // a whole (the previous projection is kept) and surfaced as a bounded | |
| 137 | + | // notice instead of silently vanishing. A merely stale publication | |
| 138 | + | // validates and is ignored without a notice. | |
| 139 | + | if snapshot.Validate() != nil { | |
| 140 | + | p.offerNotice("picker-snapshot-rejected", "broker catalogue update rejected") | |
| 141 | + | } | |
| 142 | + | return | |
| 143 | + | } | |
| 144 | + | p.mu.Lock() | |
| 145 | + | defer p.mu.Unlock() | |
| 146 | + | p.rebuildLocked() | |
| 147 | + | p.offerDiagnosticsLocked() | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | // ConsumeTerminalRead decodes one terminal read while the picker owns input, | |
| 151 | + | // applies it to the model, and records the presentation decision (with any | |
| 152 | + | // committed key) atomically under the controller lock. It reports whether the | |
| 153 | + | // read was consumed: a false result means the supervisor keeps its ordinary | |
| 154 | + | // handling. It never writes a session. | |
| 155 | + | func (p *pickerController) ConsumeTerminalRead(data []byte) bool { | |
| 156 | + | if p == nil { | |
| 157 | + | return false | |
| 158 | + | } | |
| 159 | + | return p.handleRead(data) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | // recordOpLocked accumulates one read's presentation decision. A commit also | |
| 163 | + | // captures the committed row key under the same lock, so the key and the | |
| 164 | + | // decision can never disagree. | |
| 165 | + | func (p *pickerController) recordOpLocked(op pickerOp) { | |
| 166 | + | if op.commit { | |
| 167 | + | p.lastCommitKey = p.commitKeyLocked() | |
| 168 | + | } | |
| 169 | + | p.lastOp = mergePickerOps(p.lastOp, op) | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // commitKeyLocked returns the opaque catalogue key of the row under the cursor | |
| 173 | + | // for a commit, or the empty string when no committable row is selected. | |
| 174 | + | func (p *pickerController) commitKeyLocked() string { | |
| 175 | + | if p.loop == nil { | |
| 176 | + | return "" | |
| 177 | + | } | |
| 178 | + | selection, ok := commitSelection(p.loop, protocol.PickerActionNavigate, 0) | |
| 179 | + | if !ok { | |
| 180 | + | return "" | |
| 181 | + | } | |
| 182 | + | return selection.Key | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | // TakeOp returns and clears the accumulated presentation decision since the | |
| 186 | + | // last call, together with the catalogue key of the row the commit decision | |
| 187 | + | // selected at the moment it was recorded. Resolving that key with ResolveKey | |
| 188 | + | // attaches exactly the committed row: TakeOp cannot observe a cursor that a | |
| 189 | + | // concurrent ApplySnapshot moved afterwards. The key is empty when the | |
| 190 | + | // decision carries no commit or no committable row. | |
| 191 | + | func (p *pickerController) TakeOp() (pickerOp, string) { | |
| 192 | + | if p == nil { | |
| 193 | + | return pickerOp{}, "" | |
| 194 | + | } | |
| 195 | + | p.mu.Lock() | |
| 196 | + | defer p.mu.Unlock() | |
| 197 | + | op := p.lastOp | |
| 198 | + | key := p.lastCommitKey | |
| 199 | + | p.lastOp = pickerOp{} | |
| 200 | + | p.lastCommitKey = "" | |
| 201 | + | return op, key | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // FlushPending resolves a withheld escape or UTF-8 prefix once its | |
| 205 | + | // disambiguation window expired. The bound belongs to the driver; the picker | |
| 206 | + | // only decides what the prefix means. | |
| 207 | + | func (p *pickerController) FlushPending() { | |
| 208 | + | if p == nil { | |
| 209 | + | return | |
| 210 | + | } | |
| 211 | + | p.mu.Lock() | |
| 212 | + | defer p.mu.Unlock() | |
| 213 | + | if !p.ownsInput { | |
| 214 | + | return | |
| 215 | + | } | |
| 216 | + | outcome, consumed := p.consumer.flushPending() | |
| 217 | + | if !consumed || p.loop == nil { | |
| 218 | + | return | |
| 219 | + | } | |
| 220 | + | if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration { | |
| 221 | + | return | |
| 222 | + | } | |
| 223 | + | op, _ := applyPickerBatch(p.loop, outcome.events) | |
| 224 | + | p.recordOpLocked(op) | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // OwnsInput reports whether the picker currently owns terminal input. | |
| 228 | + | func (p *pickerController) OwnsInput() bool { | |
| 229 | + | if p == nil { | |
| 230 | + | return false | |
| 231 | + | } | |
| 232 | + | p.mu.Lock() | |
| 233 | + | defer p.mu.Unlock() | |
| 234 | + | return p.ownsInput | |
| 235 | + | } | |
| 236 | + | ||
| 237 | + | // Render composes the current picker modal into terminal bytes for one display | |
| 238 | + | // size. It is a pure read of the current model: it changes no state and writes | |
| 239 | + | // nothing. | |
| 240 | + | func (p *pickerController) Render(size domain.Size) []byte { | |
| 241 | + | if p == nil { | |
| 242 | + | return nil | |
| 243 | + | } | |
| 244 | + | p.mu.Lock() | |
| 245 | + | defer p.mu.Unlock() | |
| 246 | + | if p.loop == nil { | |
| 247 | + | return nil | |
| 248 | + | } | |
| 249 | + | return p.renderer.render(p.loop, size, emptyPickerPreview()) | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // RenderNotice composes the newest bounded catalogue notice as a client-local | |
| 253 | + | // toast. It reuses the client's existing toast rendering and returns nil when | |
| 254 | + | // nothing is displayed. | |
| 255 | + | func (p *pickerController) RenderNotice(size domain.Size) []byte { | |
| 256 | + | if p == nil { | |
| 257 | + | return nil | |
| 258 | + | } | |
| 259 | + | p.mu.Lock() | |
| 260 | + | active := p.notices.Active(p.clock.Now()) | |
| 261 | + | p.mu.Unlock() | |
| 262 | + | if len(active) == 0 { | |
| 263 | + | return nil | |
| 264 | + | } | |
| 265 | + | // ToastManager.Active returns toasts in map-iteration order, so "the last | |
| 266 | + | // one" is arbitrary and can change between frames. Select the newest by | |
| 267 | + | // ShownAt, tie-broken deterministically by ID. | |
| 268 | + | newest := active[0] | |
| 269 | + | for _, toast := range active[1:] { | |
| 270 | + | if toast.ShownAt.After(newest.ShownAt) || | |
| 271 | + | (toast.ShownAt.Equal(newest.ShownAt) && toast.ID > newest.ID) { | |
| 272 | + | newest = toast | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | if newest.Message == "" { | |
| 276 | + | return nil | |
| 277 | + | } | |
| 278 | + | var buffer bytes.Buffer | |
| 279 | + | if _, err := drawClientToast(&buffer, size, newest.Message); err != nil { | |
| 280 | + | return nil | |
| 281 | + | } | |
| 282 | + | return buffer.Bytes() | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // CursorKey reports the opaque key of the row under the cursor, or false when | |
| 286 | + | // nothing qualifies. It is the selection displayed now; a commit decision is | |
| 287 | + | // captured with TakeOp instead so a concurrent publication cannot move it. | |
| 288 | + | func (p *pickerController) CursorKey() (string, bool) { | |
| 289 | + | if p == nil { | |
| 290 | + | return "", false | |
| 291 | + | } | |
| 292 | + | p.mu.Lock() | |
| 293 | + | defer p.mu.Unlock() | |
| 294 | + | key := p.commitKeyLocked() | |
| 295 | + | if key == "" { | |
| 296 | + | return "", false | |
| 297 | + | } | |
| 298 | + | return key, true | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // Resolve revalidates the row displayed now and returns the exact broker | |
| 302 | + | // stream request. A row the picker shows but does not admit an action still | |
| 303 | + | // resolves, so committing an inspectable row yields a typed catalogue refusal | |
| 304 | + | // (stale, incompatible, unavailable) instead of a generic no-selection. A | |
| 305 | + | // refusal is bounded and is also surfaced as a notice. | |
| 306 | + | // | |
| 307 | + | // Resolve reads the mutable presentation model, so a concurrent ApplySnapshot | |
| 308 | + | // can move the cursor between the caller's commit decision and this call. A | |
| 309 | + | // driver that is committing a specific row must capture that row's key with | |
| 310 | + | // the decision (TakeOp) and resolve it with ResolveKey instead. | |
| 311 | + | func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |
| 312 | + | if p == nil { | |
| 313 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | |
| 314 | + | } | |
| 315 | + | key, ok := p.displayedKey() | |
| 316 | + | if !ok { | |
| 317 | + | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "no row is selected"} | |
| 318 | + | p.offerNotice("picker-refusal", err.Error()) | |
| 319 | + | return ports.BrokerOpenStreamRequest{}, err | |
| 320 | + | } | |
| 321 | + | return p.ResolveKey(key, base) | |
| 322 | + | } | |
| 323 | + | ||
| 324 | + | // ResolveKey revalidates exactly the supplied catalogue key against the latest | |
| 325 | + | // applied snapshot and returns the exact broker stream request. It is the | |
| 326 | + | // commit path: the key captured atomically with the commit decision (see | |
| 327 | + | // TakeOp) resolves the row the user committed even if the cursor has since | |
| 328 | + | // moved. A refusal is bounded and is also surfaced as a notice. | |
| 329 | + | func (p *pickerController) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |
| 330 | + | if p == nil || p.catalogue == nil { | |
| 331 | + | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | |
| 332 | + | if p != nil { | |
| 333 | + | p.offerNotice("picker-refusal", err.Error()) | |
| 334 | + | } | |
| 335 | + | return ports.BrokerOpenStreamRequest{}, err | |
| 336 | + | } | |
| 337 | + | request, err := p.catalogue.Resolve(key, base) | |
| 338 | + | if err != nil { | |
| 339 | + | p.offerNotice("picker-refusal", err.Error()) | |
| 340 | + | return ports.BrokerOpenStreamRequest{}, err | |
| 341 | + | } | |
| 342 | + | return request, nil | |
| 343 | + | } | |
| 344 | + | ||
| 345 | + | // displayedKey names the row under the cursor, whether or not it admits an | |
| 346 | + | // action, and maps its presentation key back to the source identity. | |
| 347 | + | func (p *pickerController) displayedKey() (string, bool) { | |
| 348 | + | p.mu.Lock() | |
| 349 | + | defer p.mu.Unlock() | |
| 350 | + | if p.loop == nil || p.loop.model == nil { | |
| 351 | + | return "", false | |
| 352 | + | } | |
| 353 | + | line, ok := p.loop.model.Cursor() | |
| 354 | + | if !ok { | |
| 355 | + | return "", false | |
| 356 | + | } | |
| 357 | + | identity, ok := p.loop.rows[line.Key] | |
| 358 | + | if !ok { | |
| 359 | + | return "", false | |
| 360 | + | } | |
| 361 | + | return identity.key, true | |
| 362 | + | } | |
| 363 | + | ||
| 364 | + | func (p *pickerController) handleRead(data []byte) bool { | |
| 365 | + | p.mu.Lock() | |
| 366 | + | defer p.mu.Unlock() | |
| 367 | + | if !p.ownsInput { | |
| 368 | + | return false | |
| 369 | + | } | |
| 370 | + | if len(data) != 0 { | |
| 371 | + | data = append([]byte(nil), data...) | |
| 372 | + | } | |
| 373 | + | outcome, consumed := p.consumer.consume(terminalReadResult{data: data}) | |
| 374 | + | if !consumed { | |
| 375 | + | return false | |
| 376 | + | } | |
| 377 | + | if p.loop == nil || outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration { | |
| 378 | + | // The picker owns input before its first publication: the batch is | |
| 379 | + | // consumed and dropped, never forwarded. | |
| 380 | + | return true | |
| 381 | + | } | |
| 382 | + | op, _ := applyPickerBatch(p.loop, outcome.events) | |
| 383 | + | p.recordOpLocked(op) | |
| 384 | + | return true | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | func (p *pickerController) rebuildLocked() { | |
| 388 | + | lines, cursor := p.catalogue.projection() | |
| 389 | + | revision := uint64(p.catalogue.Revision()) | |
| 390 | + | if p.loop == nil { | |
| 391 | + | p.interaction++ | |
| 392 | + | p.loop = pickerLoopFromSnapshot(protocol.PickerSnapshot{ | |
| 393 | + | InteractionID: p.interaction, | |
| 394 | + | SourceID: pickerCatalogueSourceID, | |
| 395 | + | SourceRevision: revision, | |
| 396 | + | Lines: lines, | |
| 397 | + | Cursor: cursor, | |
| 398 | + | }, protocol.PickerIntentNavigation, defaultPickerSort()) | |
| 399 | + | return | |
| 400 | + | } | |
| 401 | + | p.loop.replaceLines(protocol.PickerSnapshot{ | |
| 402 | + | InteractionID: p.interaction, | |
| 403 | + | SourceID: pickerCatalogueSourceID, | |
| 404 | + | SourceRevision: revision, | |
| 405 | + | Lines: lines, | |
| 406 | + | Cursor: cursor, | |
| 407 | + | }) | |
| 408 | + | } | |
| 409 | + | ||
| 410 | + | // offerDiagnosticsLocked surfaces every host that is not a clean, fresh, | |
| 411 | + | // compatible, reachable daemon as a bounded toast. The toast manager keeps one | |
| 412 | + | // entry per host key, so the notices stay bounded regardless of update rate. | |
| 413 | + | func (p *pickerController) offerDiagnosticsLocked() { | |
| 414 | + | for _, diagnostic := range p.catalogue.diagnostics() { | |
| 415 | + | if diagnostic.Message == "" { | |
| 416 | + | continue | |
| 417 | + | } | |
| 418 | + | p.notices.Show(p.clock.Now(), ui.Toast{ | |
| 419 | + | ID: "picker-host:" + diagnostic.Key, | |
| 420 | + | Message: diagnostic.Origin + ": " + diagnostic.Message, | |
| 421 | + | Anchor: domain.AnchorCenter, | |
| 422 | + | Duration: pickerNoticeLifetime, | |
| 423 | + | }) | |
| 424 | + | } | |
| 425 | + | } | |
| 426 | + | ||
| 427 | + | func (p *pickerController) offerNotice(id, message string) { | |
| 428 | + | if message == "" { | |
| 429 | + | return | |
| 430 | + | } | |
| 431 | + | p.mu.Lock() | |
| 432 | + | p.notices.Show(p.clock.Now(), ui.Toast{ID: id, Message: message, Anchor: domain.AnchorCenter, Duration: pickerNoticeLifetime}) | |
| 433 | + | p.mu.Unlock() | |
| 434 | + | } | |
| 435 | + | ||
| 436 | + | // mergePickerOps accumulates two presentation decisions from one read. Close | |
| 437 | + | // wins because it retires the interaction; commit and kill both remain. | |
| 438 | + | func mergePickerOps(a, b pickerOp) pickerOp { | |
| 439 | + | return pickerOp{ | |
| 440 | + | commit: a.commit || b.commit, | |
| 441 | + | kill: a.kill || b.kill, | |
| 442 | + | close: a.close || b.close, | |
| 443 | + | } | |
| 444 | + | } | |
| @@ -0,0 +1,444 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "sync" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/vev/internal/domain" | |||
| 9 | + | "github.com/bnema/vev/internal/ports" | |||
| 10 | + | "github.com/bnema/vev/internal/protocol" | |||
| 11 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 12 | + | ) | |||
| 13 | + | ||||
| 14 | + | // Client-owned picker controller (Plan 001 P5.2b, offline and unactivated). | |||
| 15 | + | // | |||
| 16 | + | // This file is the narrow seam between the autonomous supervisor and the | |||
| 17 | + | // client-owned catalogue. The supervisor owns raw mode, the single terminal | |||
| 18 | + | // input lifetime, and the broker service; the controller owns the catalogue | |||
| 19 | + | // projection, the presentation model (reusing the picker package's cursor, | |||
| 20 | + | // search, sort, and rendering machinery), and the input decisions. | |||
| 21 | + | // | |||
| 22 | + | // Two invariants make the picker autonomous: | |||
| 23 | + | // | |||
| 24 | + | // - Zero session bytes. The controller has no session, stream, or PTY | |||
| 25 | + | // reference. A consumed terminal read is decoded into picker events and | |||
| 26 | + | // applied to the model; nothing is ever written toward a daemon. A | |||
| 27 | + | // selection resolves to a ports.BrokerOpenStreamRequest value and stops | |||
| 28 | + | // there. | |||
| 29 | + | // - No serving-daemon catalogue. Rows come from broker snapshots through | |||
| 30 | + | // pickerCatalogue, so navigation needs no serving attachment. | |||
| 31 | + | // | |||
| 32 | + | // input ownership reuses the existing pickerConsumer, so the supervisor's one | |||
| 33 | + | // terminal input lifetime feeds exactly one owner and the picker takes | |||
| 34 | + | // precedence: while the controller owns input every decoded batch is consumed | |||
| 35 | + | // and there is no fallback to a session pipeline. | |||
| 36 | + | ||||
| 37 | + | // pickerHost is the supervisor's view of the client-owned picker. The | |||
| 38 | + | // supervisor applies each broker publication and hands it every terminal read | |||
| 39 | + | // from its single input lifetime; the picker decides ownership. | |||
| 40 | + | type pickerHost interface { | |||
| 41 | + | pickerInputConsumer | |||
| 42 | + | // ApplySnapshot folds one broker publication into the catalogue and | |||
| 43 | + | // refreshes the presentation immediately. | |||
| 44 | + | ApplySnapshot(ports.BrokerSnapshot) | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // pickerInputConsumer is the supervisor's narrow input seam. ConsumeTerminalRead | |||
| 48 | + | // returns true when the picker owned and consumed the read; false leaves the | |||
| 49 | + | // read to the supervisor's ordinary (P5.1a) handling. | |||
| 50 | + | type pickerInputConsumer interface { | |||
| 51 | + | ConsumeTerminalRead(data []byte) bool | |||
| 52 | + | } | |||
| 53 | + | ||||
| 54 | + | const ( | |||
| 55 | + | // pickerGeneration identifies the single client-owned picker generation. | |||
| 56 | + | // It never changes while the controller lives; the presentation epoch is | |||
| 57 | + | // the broker epoch, not this value. | |||
| 58 | + | pickerGeneration = uint64(1) | |||
| 59 | + | // pickerNoticeLifetime bounds how long one catalogue notice is displayed. | |||
| 60 | + | pickerNoticeLifetime = 4 * time.Second | |||
| 61 | + | ) | |||
| 62 | + | ||||
| 63 | + | // pickerController owns one client-owned picker: catalogue, model, renderer, | |||
| 64 | + | // bounded notices, and input ownership. It is safe for concurrent use, so the | |||
| 65 | + | // supervisor's input lifetime and its run path may both reach it. | |||
| 66 | + | type pickerController struct { | |||
| 67 | + | clock ports.Clock | |||
| 68 | + | catalogue *pickerCatalogue | |||
| 69 | + | ||||
| 70 | + | mu sync.Mutex | |||
| 71 | + | loop *pickerLoop | |||
| 72 | + | consumer pickerConsumer | |||
| 73 | + | renderer *pickerRenderer | |||
| 74 | + | notices ui.ToastManager | |||
| 75 | + | interaction uint64 | |||
| 76 | + | ownsInput bool | |||
| 77 | + | lastOp pickerOp | |||
| 78 | + | // lastCommitKey is the catalogue key captured with the pending commit | |||
| 79 | + | // decision, so TakeOp can hand a driver the exact committed row. | |||
| 80 | + | lastCommitKey string | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | // newPickerController builds a picker over an empty catalogue. The picker owns | |||
| 84 | + | // input from the start: P5.2b presents it for the whole picker presentation and | |||
| 85 | + | // has no session pipeline to defer to. | |||
| 86 | + | func newPickerController(clock ports.Clock, freshness time.Duration) *pickerController { | |||
| 87 | + | if supervisorNil(clock) { | |||
| 88 | + | clock = systemClock{} | |||
| 89 | + | } | |||
| 90 | + | controller := &pickerController{ | |||
| 91 | + | clock: clock, | |||
| 92 | + | catalogue: newPickerCatalogue(pickerCatalogueConfig{Clock: clock, Freshness: freshness}), | |||
| 93 | + | renderer: newPickerRenderer(), | |||
| 94 | + | ownsInput: true, | |||
| 95 | + | } | |||
| 96 | + | controller.consumer.setOwned(pickerGeneration, pickerGeneration) | |||
| 97 | + | return controller | |||
| 98 | + | } | |||
| 99 | + | ||||
| 100 | + | // Catalogue exposes the projection for a driver that needs to resolve a | |||
| 101 | + | // selection or inspect the applied revision. | |||
| 102 | + | func (p *pickerController) Catalogue() *pickerCatalogue { | |||
| 103 | + | if p == nil { | |||
| 104 | + | return nil | |||
| 105 | + | } | |||
| 106 | + | return p.catalogue | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | // SetOwnsInput toggles input ownership. It is the future attach slice's release | |||
| 110 | + | // boundary; P5.2b keeps it owned for the whole run. | |||
| 111 | + | func (p *pickerController) SetOwnsInput(owns bool) { | |||
| 112 | + | if p == nil { | |||
| 113 | + | return | |||
| 114 | + | } | |||
| 115 | + | p.mu.Lock() | |||
| 116 | + | defer p.mu.Unlock() | |||
| 117 | + | if owns == p.ownsInput { | |||
| 118 | + | return | |||
| 119 | + | } | |||
| 120 | + | p.ownsInput = owns | |||
| 121 | + | if owns { | |||
| 122 | + | p.consumer.setOwned(pickerGeneration, pickerGeneration) | |||
| 123 | + | return | |||
| 124 | + | } | |||
| 125 | + | p.consumer.clear() | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // ApplySnapshot folds one broker publication into the catalogue and rebuilds | |||
| 129 | + | // the presentation synchronously, so the next Render reflects it immediately. | |||
| 130 | + | func (p *pickerController) ApplySnapshot(snapshot ports.BrokerSnapshot) { | |||
| 131 | + | if p == nil || p.catalogue == nil { | |||
| 132 | + | return | |||
| 133 | + | } | |||
| 134 | + | if !p.catalogue.Apply(snapshot) { | |||
| 135 | + | // A publication that fails the broker snapshot contract is rejected as | |||
| 136 | + | // a whole (the previous projection is kept) and surfaced as a bounded | |||
| 137 | + | // notice instead of silently vanishing. A merely stale publication | |||
| 138 | + | // validates and is ignored without a notice. | |||
| 139 | + | if snapshot.Validate() != nil { | |||
| 140 | + | p.offerNotice("picker-snapshot-rejected", "broker catalogue update rejected") | |||
| 141 | + | } | |||
| 142 | + | return | |||
| 143 | + | } | |||
| 144 | + | p.mu.Lock() | |||
| 145 | + | defer p.mu.Unlock() | |||
| 146 | + | p.rebuildLocked() | |||
| 147 | + | p.offerDiagnosticsLocked() | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | // ConsumeTerminalRead decodes one terminal read while the picker owns input, | |||
| 151 | + | // applies it to the model, and records the presentation decision (with any | |||
| 152 | + | // committed key) atomically under the controller lock. It reports whether the | |||
| 153 | + | // read was consumed: a false result means the supervisor keeps its ordinary | |||
| 154 | + | // handling. It never writes a session. | |||
| 155 | + | func (p *pickerController) ConsumeTerminalRead(data []byte) bool { | |||
| 156 | + | if p == nil { | |||
| 157 | + | return false | |||
| 158 | + | } | |||
| 159 | + | return p.handleRead(data) | |||
| 160 | + | } | |||
| 161 | + | ||||
| 162 | + | // recordOpLocked accumulates one read's presentation decision. A commit also | |||
| 163 | + | // captures the committed row key under the same lock, so the key and the | |||
| 164 | + | // decision can never disagree. | |||
| 165 | + | func (p *pickerController) recordOpLocked(op pickerOp) { | |||
| 166 | + | if op.commit { | |||
| 167 | + | p.lastCommitKey = p.commitKeyLocked() | |||
| 168 | + | } | |||
| 169 | + | p.lastOp = mergePickerOps(p.lastOp, op) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // commitKeyLocked returns the opaque catalogue key of the row under the cursor | |||
| 173 | + | // for a commit, or the empty string when no committable row is selected. | |||
| 174 | + | func (p *pickerController) commitKeyLocked() string { | |||
| 175 | + | if p.loop == nil { | |||
| 176 | + | return "" | |||
| 177 | + | } | |||
| 178 | + | selection, ok := commitSelection(p.loop, protocol.PickerActionNavigate, 0) | |||
| 179 | + | if !ok { | |||
| 180 | + | return "" | |||
| 181 | + | } | |||
| 182 | + | return selection.Key | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | // TakeOp returns and clears the accumulated presentation decision since the | |||
| 186 | + | // last call, together with the catalogue key of the row the commit decision | |||
| 187 | + | // selected at the moment it was recorded. Resolving that key with ResolveKey | |||
| 188 | + | // attaches exactly the committed row: TakeOp cannot observe a cursor that a | |||
| 189 | + | // concurrent ApplySnapshot moved afterwards. The key is empty when the | |||
| 190 | + | // decision carries no commit or no committable row. | |||
| 191 | + | func (p *pickerController) TakeOp() (pickerOp, string) { | |||
| 192 | + | if p == nil { | |||
| 193 | + | return pickerOp{}, "" | |||
| 194 | + | } | |||
| 195 | + | p.mu.Lock() | |||
| 196 | + | defer p.mu.Unlock() | |||
| 197 | + | op := p.lastOp | |||
| 198 | + | key := p.lastCommitKey | |||
| 199 | + | p.lastOp = pickerOp{} | |||
| 200 | + | p.lastCommitKey = "" | |||
| 201 | + | return op, key | |||
| 202 | + | } | |||
| 203 | + | ||||
| 204 | + | // FlushPending resolves a withheld escape or UTF-8 prefix once its | |||
| 205 | + | // disambiguation window expired. The bound belongs to the driver; the picker | |||
| 206 | + | // only decides what the prefix means. | |||
| 207 | + | func (p *pickerController) FlushPending() { | |||
| 208 | + | if p == nil { | |||
| 209 | + | return | |||
| 210 | + | } | |||
| 211 | + | p.mu.Lock() | |||
| 212 | + | defer p.mu.Unlock() | |||
| 213 | + | if !p.ownsInput { | |||
| 214 | + | return | |||
| 215 | + | } | |||
| 216 | + | outcome, consumed := p.consumer.flushPending() | |||
| 217 | + | if !consumed || p.loop == nil { | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 220 | + | if outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration { | |||
| 221 | + | return | |||
| 222 | + | } | |||
| 223 | + | op, _ := applyPickerBatch(p.loop, outcome.events) | |||
| 224 | + | p.recordOpLocked(op) | |||
| 225 | + | } | |||
| 226 | + | ||||
| 227 | + | // OwnsInput reports whether the picker currently owns terminal input. | |||
| 228 | + | func (p *pickerController) OwnsInput() bool { | |||
| 229 | + | if p == nil { | |||
| 230 | + | return false | |||
| 231 | + | } | |||
| 232 | + | p.mu.Lock() | |||
| 233 | + | defer p.mu.Unlock() | |||
| 234 | + | return p.ownsInput | |||
| 235 | + | } | |||
| 236 | + | ||||
| 237 | + | // Render composes the current picker modal into terminal bytes for one display | |||
| 238 | + | // size. It is a pure read of the current model: it changes no state and writes | |||
| 239 | + | // nothing. | |||
| 240 | + | func (p *pickerController) Render(size domain.Size) []byte { | |||
| 241 | + | if p == nil { | |||
| 242 | + | return nil | |||
| 243 | + | } | |||
| 244 | + | p.mu.Lock() | |||
| 245 | + | defer p.mu.Unlock() | |||
| 246 | + | if p.loop == nil { | |||
| 247 | + | return nil | |||
| 248 | + | } | |||
| 249 | + | return p.renderer.render(p.loop, size, emptyPickerPreview()) | |||
| 250 | + | } | |||
| 251 | + | ||||
| 252 | + | // RenderNotice composes the newest bounded catalogue notice as a client-local | |||
| 253 | + | // toast. It reuses the client's existing toast rendering and returns nil when | |||
| 254 | + | // nothing is displayed. | |||
| 255 | + | func (p *pickerController) RenderNotice(size domain.Size) []byte { | |||
| 256 | + | if p == nil { | |||
| 257 | + | return nil | |||
| 258 | + | } | |||
| 259 | + | p.mu.Lock() | |||
| 260 | + | active := p.notices.Active(p.clock.Now()) | |||
| 261 | + | p.mu.Unlock() | |||
| 262 | + | if len(active) == 0 { | |||
| 263 | + | return nil | |||
| 264 | + | } | |||
| 265 | + | // ToastManager.Active returns toasts in map-iteration order, so "the last | |||
| 266 | + | // one" is arbitrary and can change between frames. Select the newest by | |||
| 267 | + | // ShownAt, tie-broken deterministically by ID. | |||
| 268 | + | newest := active[0] | |||
| 269 | + | for _, toast := range active[1:] { | |||
| 270 | + | if toast.ShownAt.After(newest.ShownAt) || | |||
| 271 | + | (toast.ShownAt.Equal(newest.ShownAt) && toast.ID > newest.ID) { | |||
| 272 | + | newest = toast | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | if newest.Message == "" { | |||
| 276 | + | return nil | |||
| 277 | + | } | |||
| 278 | + | var buffer bytes.Buffer | |||
| 279 | + | if _, err := drawClientToast(&buffer, size, newest.Message); err != nil { | |||
| 280 | + | return nil | |||
| 281 | + | } | |||
| 282 | + | return buffer.Bytes() | |||
| 283 | + | } | |||
| 284 | + | ||||
| 285 | + | // CursorKey reports the opaque key of the row under the cursor, or false when | |||
| 286 | + | // nothing qualifies. It is the selection displayed now; a commit decision is | |||
| 287 | + | // captured with TakeOp instead so a concurrent publication cannot move it. | |||
| 288 | + | func (p *pickerController) CursorKey() (string, bool) { | |||
| 289 | + | if p == nil { | |||
| 290 | + | return "", false | |||
| 291 | + | } | |||
| 292 | + | p.mu.Lock() | |||
| 293 | + | defer p.mu.Unlock() | |||
| 294 | + | key := p.commitKeyLocked() | |||
| 295 | + | if key == "" { | |||
| 296 | + | return "", false | |||
| 297 | + | } | |||
| 298 | + | return key, true | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | // Resolve revalidates the row displayed now and returns the exact broker | |||
| 302 | + | // stream request. A row the picker shows but does not admit an action still | |||
| 303 | + | // resolves, so committing an inspectable row yields a typed catalogue refusal | |||
| 304 | + | // (stale, incompatible, unavailable) instead of a generic no-selection. A | |||
| 305 | + | // refusal is bounded and is also surfaced as a notice. | |||
| 306 | + | // | |||
| 307 | + | // Resolve reads the mutable presentation model, so a concurrent ApplySnapshot | |||
| 308 | + | // can move the cursor between the caller's commit decision and this call. A | |||
| 309 | + | // driver that is committing a specific row must capture that row's key with | |||
| 310 | + | // the decision (TakeOp) and resolve it with ResolveKey instead. | |||
| 311 | + | func (p *pickerController) Resolve(base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |||
| 312 | + | if p == nil { | |||
| 313 | + | return ports.BrokerOpenStreamRequest{}, pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | |||
| 314 | + | } | |||
| 315 | + | key, ok := p.displayedKey() | |||
| 316 | + | if !ok { | |||
| 317 | + | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "no row is selected"} | |||
| 318 | + | p.offerNotice("picker-refusal", err.Error()) | |||
| 319 | + | return ports.BrokerOpenStreamRequest{}, err | |||
| 320 | + | } | |||
| 321 | + | return p.ResolveKey(key, base) | |||
| 322 | + | } | |||
| 323 | + | ||||
| 324 | + | // ResolveKey revalidates exactly the supplied catalogue key against the latest | |||
| 325 | + | // applied snapshot and returns the exact broker stream request. It is the | |||
| 326 | + | // commit path: the key captured atomically with the commit decision (see | |||
| 327 | + | // TakeOp) resolves the row the user committed even if the cursor has since | |||
| 328 | + | // moved. A refusal is bounded and is also surfaced as a notice. | |||
| 329 | + | func (p *pickerController) ResolveKey(key string, base pickerResolveBase) (ports.BrokerOpenStreamRequest, error) { | |||
| 330 | + | if p == nil || p.catalogue == nil { | |||
| 331 | + | err := pickerCatalogueError{Code: pickerCatalogueNoSelection, Text: "picker is not available"} | |||
| 332 | + | if p != nil { | |||
| 333 | + | p.offerNotice("picker-refusal", err.Error()) | |||
| 334 | + | } | |||
| 335 | + | return ports.BrokerOpenStreamRequest{}, err | |||
| 336 | + | } | |||
| 337 | + | request, err := p.catalogue.Resolve(key, base) | |||
| 338 | + | if err != nil { | |||
| 339 | + | p.offerNotice("picker-refusal", err.Error()) | |||
| 340 | + | return ports.BrokerOpenStreamRequest{}, err | |||
| 341 | + | } | |||
| 342 | + | return request, nil | |||
| 343 | + | } | |||
| 344 | + | ||||
| 345 | + | // displayedKey names the row under the cursor, whether or not it admits an | |||
| 346 | + | // action, and maps its presentation key back to the source identity. | |||
| 347 | + | func (p *pickerController) displayedKey() (string, bool) { | |||
| 348 | + | p.mu.Lock() | |||
| 349 | + | defer p.mu.Unlock() | |||
| 350 | + | if p.loop == nil || p.loop.model == nil { | |||
| 351 | + | return "", false | |||
| 352 | + | } | |||
| 353 | + | line, ok := p.loop.model.Cursor() | |||
| 354 | + | if !ok { | |||
| 355 | + | return "", false | |||
| 356 | + | } | |||
| 357 | + | identity, ok := p.loop.rows[line.Key] | |||
| 358 | + | if !ok { | |||
| 359 | + | return "", false | |||
| 360 | + | } | |||
| 361 | + | return identity.key, true | |||
| 362 | + | } | |||
| 363 | + | ||||
| 364 | + | func (p *pickerController) handleRead(data []byte) bool { | |||
| 365 | + | p.mu.Lock() | |||
| 366 | + | defer p.mu.Unlock() | |||
| 367 | + | if !p.ownsInput { | |||
| 368 | + | return false | |||
| 369 | + | } | |||
| 370 | + | if len(data) != 0 { | |||
| 371 | + | data = append([]byte(nil), data...) | |||
| 372 | + | } | |||
| 373 | + | outcome, consumed := p.consumer.consume(terminalReadResult{data: data}) | |||
| 374 | + | if !consumed { | |||
| 375 | + | return false | |||
| 376 | + | } | |||
| 377 | + | if p.loop == nil || outcome.interaction != pickerGeneration || outcome.generation != pickerGeneration { | |||
| 378 | + | // The picker owns input before its first publication: the batch is | |||
| 379 | + | // consumed and dropped, never forwarded. | |||
| 380 | + | return true | |||
| 381 | + | } | |||
| 382 | + | op, _ := applyPickerBatch(p.loop, outcome.events) | |||
| 383 | + | p.recordOpLocked(op) | |||
| 384 | + | return true | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | func (p *pickerController) rebuildLocked() { | |||
| 388 | + | lines, cursor := p.catalogue.projection() | |||
| 389 | + | revision := uint64(p.catalogue.Revision()) | |||
| 390 | + | if p.loop == nil { | |||
| 391 | + | p.interaction++ | |||
| 392 | + | p.loop = pickerLoopFromSnapshot(protocol.PickerSnapshot{ | |||
| 393 | + | InteractionID: p.interaction, | |||
| 394 | + | SourceID: pickerCatalogueSourceID, | |||
| 395 | + | SourceRevision: revision, | |||
| 396 | + | Lines: lines, | |||
| 397 | + | Cursor: cursor, | |||
| 398 | + | }, protocol.PickerIntentNavigation, defaultPickerSort()) | |||
| 399 | + | return | |||
| 400 | + | } | |||
| 401 | + | p.loop.replaceLines(protocol.PickerSnapshot{ | |||
| 402 | + | InteractionID: p.interaction, | |||
| 403 | + | SourceID: pickerCatalogueSourceID, | |||
| 404 | + | SourceRevision: revision, | |||
| 405 | + | Lines: lines, | |||
| 406 | + | Cursor: cursor, | |||
| 407 | + | }) | |||
| 408 | + | } | |||
| 409 | + | ||||
| 410 | + | // offerDiagnosticsLocked surfaces every host that is not a clean, fresh, | |||
| 411 | + | // compatible, reachable daemon as a bounded toast. The toast manager keeps one | |||
| 412 | + | // entry per host key, so the notices stay bounded regardless of update rate. | |||
| 413 | + | func (p *pickerController) offerDiagnosticsLocked() { | |||
| 414 | + | for _, diagnostic := range p.catalogue.diagnostics() { | |||
| 415 | + | if diagnostic.Message == "" { | |||
| 416 | + | continue | |||
| 417 | + | } | |||
| 418 | + | p.notices.Show(p.clock.Now(), ui.Toast{ | |||
| 419 | + | ID: "picker-host:" + diagnostic.Key, | |||
| 420 | + | Message: diagnostic.Origin + ": " + diagnostic.Message, | |||
| 421 | + | Anchor: domain.AnchorCenter, | |||
| 422 | + | Duration: pickerNoticeLifetime, | |||
| 423 | + | }) | |||
| 424 | + | } | |||
| 425 | + | } | |||
| 426 | + | ||||
| 427 | + | func (p *pickerController) offerNotice(id, message string) { | |||
| 428 | + | if message == "" { | |||
| 429 | + | return | |||
| 430 | + | } | |||
| 431 | + | p.mu.Lock() | |||
| 432 | + | p.notices.Show(p.clock.Now(), ui.Toast{ID: id, Message: message, Anchor: domain.AnchorCenter, Duration: pickerNoticeLifetime}) | |||
| 433 | + | p.mu.Unlock() | |||
| 434 | + | } | |||
| 435 | + | ||||
| 436 | + | // mergePickerOps accumulates two presentation decisions from one read. Close | |||
| 437 | + | // wins because it retires the interaction; commit and kill both remain. | |||
| 438 | + | func mergePickerOps(a, b pickerOp) pickerOp { | |||
| 439 | + | return pickerOp{ | |||
| 440 | + | commit: a.commit || b.commit, | |||
| 441 | + | kill: a.kill || b.kill, | |||
| 442 | + | close: a.close || b.close, | |||
| 443 | + | } | |||
| 444 | + | } | |||
A internal/usecase/client/picker_controller_test.go +624 -0
| @@ -0,0 +1,624 @@ | |||
| 1 | + | package client | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "io" | |
| 7 | + | "sync" | |
| 8 | + | "testing" | |
| 9 | + | "time" | |
| 10 | + | ||
| 11 | + | "github.com/stretchr/testify/require" | |
| 12 | + | ||
| 13 | + | "github.com/bnema/vev/internal/domain" | |
| 14 | + | "github.com/bnema/vev/internal/ports" | |
| 15 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |
| 16 | + | "github.com/bnema/vev/internal/usecase/ui" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // Client-owned picker controller tests (Plan 001 P5.2b): rendering, local | |
| 20 | + | // search/cursor behaviour across live updates, and the input-ownership proof | |
| 21 | + | // that picker input never reaches a session. | |
| 22 | + | ||
| 23 | + | // pickerChunkReader yields pre-pushed chunks to the supervisor's single input | |
| 24 | + | // lifetime. push blocks until the lifetime reads the chunk, so a test can | |
| 25 | + | // order a read deterministically without a sleep. | |
| 26 | + | type pickerChunkReader struct { | |
| 27 | + | chunks chan []byte | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | func newPickerChunkReader() *pickerChunkReader { | |
| 31 | + | return &pickerChunkReader{chunks: make(chan []byte)} | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | func (r *pickerChunkReader) push(data []byte) { | |
| 35 | + | r.chunks <- append([]byte(nil), data...) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func (r *pickerChunkReader) close() { close(r.chunks) } | |
| 39 | + | ||
| 40 | + | func (r *pickerChunkReader) Read(buffer []byte) (int, error) { | |
| 41 | + | chunk, ok := <-r.chunks | |
| 42 | + | if !ok { | |
| 43 | + | return 0, io.EOF | |
| 44 | + | } | |
| 45 | + | return copy(buffer, chunk), nil | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // pickerInputSpy forwards each read to the real consumer and signals that it | |
| 49 | + | // was delivered, so a test can wait exactly for consumption. | |
| 50 | + | type pickerInputSpy struct { | |
| 51 | + | inner pickerInputConsumer | |
| 52 | + | done chan struct{} | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func newPickerInputSpy(inner pickerInputConsumer) *pickerInputSpy { | |
| 56 | + | return &pickerInputSpy{inner: inner, done: make(chan struct{}, 1)} | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (s *pickerInputSpy) ConsumeTerminalRead(data []byte) bool { | |
| 60 | + | consumed := s.inner.ConsumeTerminalRead(data) | |
| 61 | + | select { | |
| 62 | + | case s.done <- struct{}{}: | |
| 63 | + | default: | |
| 64 | + | } | |
| 65 | + | return consumed | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | func (s *pickerInputSpy) await(t *testing.T) { | |
| 69 | + | t.Helper() | |
| 70 | + | select { | |
| 71 | + | case <-s.done: | |
| 72 | + | case <-time.After(5 * time.Second): | |
| 73 | + | t.Fatal("terminal input was not consumed") | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // pickerRecordingTerminal records every byte written to the terminal output, | |
| 78 | + | // which is where a leaked picker key would surface if it reached a session. | |
| 79 | + | type pickerRecordingTerminal struct { | |
| 80 | + | in io.Reader | |
| 81 | + | out bytes.Buffer | |
| 82 | + | mu sync.Mutex | |
| 83 | + | enters int | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func (t *pickerRecordingTerminal) EnterRaw() (func() error, error) { | |
| 87 | + | t.mu.Lock() | |
| 88 | + | t.enters++ | |
| 89 | + | t.mu.Unlock() | |
| 90 | + | return func() error { return nil }, nil | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func (t *pickerRecordingTerminal) Geometry() (domain.Geometry, error) { | |
| 94 | + | return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (t *pickerRecordingTerminal) ResizeEvents() <-chan domain.Geometry { return nil } | |
| 98 | + | ||
| 99 | + | func (t *pickerRecordingTerminal) In() io.Reader { return t.in } | |
| 100 | + | ||
| 101 | + | func (t *pickerRecordingTerminal) Out() io.Writer { return &t.out } | |
| 102 | + | ||
| 103 | + | func (t *pickerRecordingTerminal) Flush() error { return nil } | |
| 104 | + | ||
| 105 | + | func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision, now time.Time, sessions ...string) ports.BrokerSnapshot { | |
| 106 | + | catalogSessions := make([]catalogue.RemoteCatalogSession, 0, len(sessions)) | |
| 107 | + | for i, name := range sessions { | |
| 108 | + | catalogSessions = append(catalogSessions, pickerTestSession(name, byte(i+1), catalogue_Up)) | |
| 109 | + | } | |
| 110 | + | return ports.BrokerSnapshot{Epoch: epoch, Revision: revision, Daemons: []ports.BrokerDaemonObservation{ | |
| 111 | + | pickerTestLocalObservation(now, catalogSessions...), | |
| 112 | + | }} | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) { | |
| 116 | + | t.Helper() | |
| 117 | + | clock := newSupervisorTestClock() | |
| 118 | + | return newPickerController(clock, pickerTestFreshness), clock | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string { | |
| 122 | + | t.Helper() | |
| 123 | + | line, ok := pickerLineByLabel(controller.Catalogue().Lines(), label) | |
| 124 | + | require.True(t, ok, "row %q must be projected", label) | |
| 125 | + | return line.Key | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func mustCursorKey(t *testing.T, controller *pickerController) string { | |
| 129 | + | t.Helper() | |
| 130 | + | key, ok := controller.CursorKey() | |
| 131 | + | require.True(t, ok, "the picker must have a selected row") | |
| 132 | + | return key | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (p *pickerController) modelQuery(t *testing.T) string { | |
| 136 | + | t.Helper() | |
| 137 | + | p.mu.Lock() | |
| 138 | + | defer p.mu.Unlock() | |
| 139 | + | require.NotNil(t, p.loop) | |
| 140 | + | return p.loop.model.Query() | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func (p *pickerController) modelSearchActive(t *testing.T) bool { | |
| 144 | + | t.Helper() | |
| 145 | + | p.mu.Lock() | |
| 146 | + | defer p.mu.Unlock() | |
| 147 | + | require.NotNil(t, p.loop) | |
| 148 | + | return p.loop.model.SearchActive() | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestPickerControllerRendersLatestSnapshotImmediately(t *testing.T) { | |
| 152 | + | controller, clock := pickerTestController(t) | |
| 153 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |
| 154 | + | firstRender := controller.Render(domain.Size{Cols: 80, Rows: 24}) | |
| 155 | + | require.NotEmpty(t, firstRender) | |
| 156 | + | alphaKey := pickerRowKeyByLabel(t, controller, "alpha") | |
| 157 | + | require.Equal(t, alphaKey, mustCursorKey(t, controller)) | |
| 158 | + | ||
| 159 | + | // A newer publication is folded synchronously: the very next render already | |
| 160 | + | // reflects it, with no debounce or intervening frame. | |
| 161 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "beta")) | |
| 162 | + | require.Equal(t, pickerRowKeyByLabel(t, controller, "beta"), mustCursorKey(t, controller)) | |
| 163 | + | require.NotEmpty(t, controller.Render(domain.Size{Cols: 80, Rows: 24})) | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func TestPickerControllerSearchSurvivesHostUpdate(t *testing.T) { | |
| 167 | + | controller, clock := pickerTestController(t) | |
| 168 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |
| 169 | + | ||
| 170 | + | spy := newPickerInputSpy(controller) | |
| 171 | + | reader := newPickerChunkReader() | |
| 172 | + | t.Cleanup(reader.close) | |
| 173 | + | _ = startTerminalInputLifetime(reader, spy) | |
| 174 | + | ||
| 175 | + | reader.push([]byte("/alp")) | |
| 176 | + | spy.await(t) | |
| 177 | + | require.Equal(t, "alp", controller.modelQuery(t)) | |
| 178 | + | require.True(t, controller.modelSearchActive(t)) | |
| 179 | + | ||
| 180 | + | // A host update while the query is typed keeps the editor and the matched | |
| 181 | + | // cursor; it never resets the local search. | |
| 182 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta", "gamma")) | |
| 183 | + | require.Equal(t, "alp", controller.modelQuery(t)) | |
| 184 | + | require.True(t, controller.modelSearchActive(t)) | |
| 185 | + | require.Equal(t, pickerRowKeyByLabel(t, controller, "alpha"), mustCursorKey(t, controller)) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // TestPickerControllerInputNeverReachesPTY feeds every input shape a picker can | |
| 189 | + | // receive and proves the only effects are local: each batch is consumed, the | |
| 190 | + | // terminal receives no bytes, and the decisions are presentation facts, never a | |
| 191 | + | // session write. | |
| 192 | + | func TestPickerControllerInputNeverReachesPTY(t *testing.T) { | |
| 193 | + | controller, clock := pickerTestController(t) | |
| 194 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |
| 195 | + | ||
| 196 | + | alphaKey := pickerRowKeyByLabel(t, controller, "alpha") | |
| 197 | + | betaKey := pickerRowKeyByLabel(t, controller, "beta") | |
| 198 | + | ||
| 199 | + | spy := newPickerInputSpy(controller) | |
| 200 | + | reader := newPickerChunkReader() | |
| 201 | + | t.Cleanup(reader.close) | |
| 202 | + | terminal := &pickerRecordingTerminal{in: reader} | |
| 203 | + | _ = startTerminalInputLifetime(terminal.In(), spy) | |
| 204 | + | ||
| 205 | + | chunks := [][]byte{ | |
| 206 | + | []byte("\x1b[B"), // Down -> beta | |
| 207 | + | []byte("k"), // up -> alpha | |
| 208 | + | []byte("j"), // down -> beta | |
| 209 | + | []byte("\x1b[A"), // Up -> alpha | |
| 210 | + | []byte("\x1b[<0;10;5M"), // mouse report: consumed and dropped | |
| 211 | + | []byte("\x1b[200~rm -rf /\x1b[201~"), // bracketed paste: content dropped | |
| 212 | + | []byte("\x00\x01\x7f"), // control bytes: consumed and dropped | |
| 213 | + | []byte("\x1b"), // lone ESC withheld for disambiguation | |
| 214 | + | } | |
| 215 | + | for _, chunk := range chunks { | |
| 216 | + | reader.push(chunk) | |
| 217 | + | spy.await(t) | |
| 218 | + | } | |
| 219 | + | require.Equal(t, alphaKey, mustCursorKey(t, controller), "arrow keys moved the local cursor") | |
| 220 | + | require.False(t, controller.modelSearchActive(t), "mouse, paste, and control bytes never opened search") | |
| 221 | + | require.Equal(t, betaKey, pickerRowKeyByLabel(t, controller, "beta")) | |
| 222 | + | ||
| 223 | + | // A withheld lone ESC resolves through the bounded window as one Escape. | |
| 224 | + | controller.FlushPending() | |
| 225 | + | op, _ := controller.TakeOp() | |
| 226 | + | require.True(t, op.close, "Escape retires the picker locally") | |
| 227 | + | ||
| 228 | + | require.Empty(t, terminal.out.Bytes(), "the picker wrote nothing to the terminal") | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | func TestPickerControllerInputRouting(t *testing.T) { | |
| 232 | + | tests := []struct { | |
| 233 | + | name string | |
| 234 | + | chunk []byte | |
| 235 | + | flush bool | |
| 236 | + | wantCursor string | |
| 237 | + | wantNoSelection bool | |
| 238 | + | wantSearch bool | |
| 239 | + | wantQuery string | |
| 240 | + | wantOp pickerOp | |
| 241 | + | }{ | |
| 242 | + | {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"}, | |
| 243 | + | {name: "up rests on the host row", chunk: []byte("\x1b[A"), wantNoSelection: true}, | |
| 244 | + | {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true}, | |
| 245 | + | {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"}, | |
| 246 | + | {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}}, | |
| 247 | + | {name: "kill", chunk: []byte("x"), wantCursor: "alpha", wantOp: pickerOp{kill: true}}, | |
| 248 | + | {name: "escape exits search first", chunk: []byte("/al\x1b"), flush: true, wantCursor: "alpha"}, | |
| 249 | + | {name: "escape closes", chunk: []byte("\x1b"), flush: true, wantCursor: "alpha", wantOp: pickerOp{close: true}}, | |
| 250 | + | {name: "f1 is consumed", chunk: []byte("\x1b[11~"), wantCursor: "alpha"}, | |
| 251 | + | } | |
| 252 | + | ||
| 253 | + | for _, tt := range tests { | |
| 254 | + | t.Run(tt.name, func(t *testing.T) { | |
| 255 | + | controller, clock := pickerTestController(t) | |
| 256 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |
| 257 | + | alphaKey := pickerRowKeyByLabel(t, controller, "alpha") | |
| 258 | + | spy := newPickerInputSpy(controller) | |
| 259 | + | reader := newPickerChunkReader() | |
| 260 | + | t.Cleanup(reader.close) | |
| 261 | + | _ = startTerminalInputLifetime(reader, spy) | |
| 262 | + | ||
| 263 | + | reader.push(tt.chunk) | |
| 264 | + | spy.await(t) | |
| 265 | + | if tt.flush { | |
| 266 | + | controller.FlushPending() | |
| 267 | + | } | |
| 268 | + | if tt.wantOp != (pickerOp{}) { | |
| 269 | + | op, _ := controller.TakeOp() | |
| 270 | + | require.Equal(t, tt.wantOp, op) | |
| 271 | + | } | |
| 272 | + | require.Equal(t, tt.wantSearch, controller.modelSearchActive(t)) | |
| 273 | + | if tt.wantQuery != "" { | |
| 274 | + | require.Equal(t, tt.wantQuery, controller.modelQuery(t)) | |
| 275 | + | } | |
| 276 | + | if tt.wantNoSelection { | |
| 277 | + | _, ok := controller.CursorKey() | |
| 278 | + | require.False(t, ok, "the host row is a cursor destination but not a selection") | |
| 279 | + | return | |
| 280 | + | } | |
| 281 | + | want := alphaKey | |
| 282 | + | if tt.wantCursor == "beta" { | |
| 283 | + | want = pickerRowKeyByLabel(t, controller, "beta") | |
| 284 | + | } | |
| 285 | + | require.Equal(t, want, mustCursorKey(t, controller)) | |
| 286 | + | }) | |
| 287 | + | } | |
| 288 | + | } | |
| 289 | + | ||
| 290 | + | // TestPickerControllerNotOwningInputLeavesReadUnconsumed pins the ownership | |
| 291 | + | // boundary: once the picker releases input, a read is not consumed, so a later | |
| 292 | + | // attach slice keeps its ordinary pipeline. | |
| 293 | + | func TestPickerControllerNotOwningInputLeavesReadUnconsumed(t *testing.T) { | |
| 294 | + | controller, clock := pickerTestController(t) | |
| 295 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |
| 296 | + | controller.SetOwnsInput(false) | |
| 297 | + | require.False(t, controller.ConsumeTerminalRead([]byte("j"))) | |
| 298 | + | controller.SetOwnsInput(true) | |
| 299 | + | require.True(t, controller.ConsumeTerminalRead([]byte("j"))) | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func TestPickerControllerSurfacesProgressAndFailuresAsNotices(t *testing.T) { | |
| 303 | + | controller, clock := pickerTestController(t) | |
| 304 | + | checking := pickerTestLocalObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | |
| 305 | + | checking.Checking = true | |
| 306 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}}) | |
| 307 | + | require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "a refreshing host is surfaced as progress") | |
| 308 | + | ||
| 309 | + | unreachable := pickerTestLocalObservation(clock.Now()) | |
| 310 | + | unreachable.Availability = domain.RemoteAvailabilityUnreachable | |
| 311 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{unreachable}}) | |
| 312 | + | require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "an unavailable host is surfaced as a failure") | |
| 313 | + | } | |
| 314 | + | ||
| 315 | + | // TestPickerControllerResolveRefusesInspectableRow: a row the picker displays | |
| 316 | + | // for inspection (a stale host) still resolves to a typed refusal, so the user | |
| 317 | + | // never gets a generic failure for a known cause. | |
| 318 | + | func TestPickerControllerResolveRefusesInspectableRow(t *testing.T) { | |
| 319 | + | controller, clock := pickerTestController(t) | |
| 320 | + | stale := pickerTestLocalObservation(clock.Now().Add(-2*pickerTestFreshness), pickerTestSession("alpha", 1, catalogue_Up)) | |
| 321 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{stale}}) | |
| 322 | + | ||
| 323 | + | // The cursor first rests on the host status row, then reaches the stale | |
| 324 | + | // session row; neither admits an action. | |
| 325 | + | _, ok := controller.CursorKey() | |
| 326 | + | require.False(t, ok) | |
| 327 | + | controller.ConsumeTerminalRead([]byte("j")) | |
| 328 | + | require.False(t, controller.modelSearchActive(t)) | |
| 329 | + | ||
| 330 | + | _, err := controller.Resolve(pickerTestBase()) | |
| 331 | + | require.Error(t, err) | |
| 332 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueStale), "a stale row refuses with its exact cause") | |
| 333 | + | require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24})) | |
| 334 | + | } | |
| 335 | + | ||
| 336 | + | // TestPickerControllerResolveUsesLatestSnapshot proves resolution revalidates | |
| 337 | + | // against the snapshot in force at resolve time, not the one displayed. | |
| 338 | + | func TestPickerControllerResolveUsesLatestSnapshot(t *testing.T) { | |
| 339 | + | controller, clock := pickerTestController(t) | |
| 340 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |
| 341 | + | key := mustCursorKey(t, controller) | |
| 342 | + | ||
| 343 | + | // The host disappears before the selection resolves. | |
| 344 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2}) | |
| 345 | + | ||
| 346 | + | _, err := controller.Resolve(pickerTestBase()) | |
| 347 | + | require.Error(t, err) | |
| 348 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueNoSelection), "the vanished row leaves no selection") | |
| 349 | + | _, err = controller.Catalogue().Resolve(key, pickerTestBase()) | |
| 350 | + | require.Error(t, err) | |
| 351 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueGone)) | |
| 352 | + | require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "the refusal is surfaced as a bounded notice") | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | func TestPickerControllerResolveReturnsExactRequest(t *testing.T) { | |
| 356 | + | controller, clock := pickerTestController(t) | |
| 357 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |
| 358 | + | ||
| 359 | + | request, err := controller.Resolve(pickerTestBase()) | |
| 360 | + | require.NoError(t, err) | |
| 361 | + | require.NoError(t, request.Validate()) | |
| 362 | + | require.Equal(t, ports.BrokerAdmissionExact, request.Admission) | |
| 363 | + | require.Equal(t, ports.BrokerStreamAttachment, request.Purpose) | |
| 364 | + | require.Equal(t, "alpha", request.Target.SessionName) | |
| 365 | + | } | |
| 366 | + | ||
| 367 | + | // TestPickerControllerConcurrentUpdatesAndInput exercises the mutex boundaries | |
| 368 | + | // under -race: publications, input consumption, rendering, and resolution run | |
| 369 | + | // concurrently and must not race or panic. | |
| 370 | + | func TestPickerControllerConcurrentUpdatesAndInput(t *testing.T) { | |
| 371 | + | const iterations = 200 | |
| 372 | + | controller, clock := pickerTestController(t) | |
| 373 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |
| 374 | + | ||
| 375 | + | var wait sync.WaitGroup | |
| 376 | + | wait.Add(3) | |
| 377 | + | ||
| 378 | + | go func() { | |
| 379 | + | defer wait.Done() | |
| 380 | + | for revision := ports.BrokerRevision(2); revision < 2+iterations; revision++ { | |
| 381 | + | controller.ApplySnapshot(pickerTestSnapshot(3, revision, clock.Now(), "alpha", "beta")) | |
| 382 | + | } | |
| 383 | + | }() | |
| 384 | + | ||
| 385 | + | go func() { | |
| 386 | + | defer wait.Done() | |
| 387 | + | for range iterations { | |
| 388 | + | controller.ConsumeTerminalRead([]byte("\x1b[B\x1b[Aj")) | |
| 389 | + | controller.FlushPending() | |
| 390 | + | controller.TakeOp() | |
| 391 | + | } | |
| 392 | + | }() | |
| 393 | + | ||
| 394 | + | go func() { | |
| 395 | + | defer wait.Done() | |
| 396 | + | for range iterations { | |
| 397 | + | _ = controller.Render(domain.Size{Cols: 80, Rows: 24}) | |
| 398 | + | _ = controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}) | |
| 399 | + | _, _ = controller.Resolve(pickerTestBase()) | |
| 400 | + | _, _ = controller.CursorKey() | |
| 401 | + | } | |
| 402 | + | }() | |
| 403 | + | ||
| 404 | + | wait.Wait() | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | // TestSupervisorPickerFoldsPublicationsAndInput proves the supervisor wiring: | |
| 408 | + | // a broker publication reaches the picker through the established connection's | |
| 409 | + | // subscription, and terminal input is consumed by the picker from the same | |
| 410 | + | // single input lifetime the supervisor uses for EOF, never written to the | |
| 411 | + | // terminal. | |
| 412 | + | func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) { | |
| 413 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 414 | + | defer cancel() | |
| 415 | + | ||
| 416 | + | clock := newSupervisorTestClock() | |
| 417 | + | reader := newPickerChunkReader() | |
| 418 | + | t.Cleanup(reader.close) | |
| 419 | + | terminal := &pickerRecordingTerminal{in: reader} | |
| 420 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |
| 421 | + | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |
| 422 | + | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |
| 423 | + | return service, nil | |
| 424 | + | }) | |
| 425 | + | controller := newPickerController(clock, pickerTestFreshness) | |
| 426 | + | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | |
| 427 | + | ||
| 428 | + | runErr := make(chan error, 1) | |
| 429 | + | go func() { runErr <- sup.Run(ctx) }() | |
| 430 | + | ||
| 431 | + | require.Eventually(t, func() bool { | |
| 432 | + | return controller.Catalogue().Epoch() == 3 | |
| 433 | + | }, time.Second, time.Millisecond, "the supervisor folds the committed publication into the picker") | |
| 434 | + | require.Equal(t, []string{"alpha", "beta"}, pickerSessionLabels(controller.Catalogue().Lines())) | |
| 435 | + | require.True(t, controller.OwnsInput()) | |
| 436 | + | ||
| 437 | + | // The same input lifetime the supervisor uses for EOF delivers the read to | |
| 438 | + | // the picker: the cursor moves locally and nothing is written anywhere. | |
| 439 | + | betaKey := pickerRowKeyByLabel(t, controller, "beta") | |
| 440 | + | reader.push([]byte("\x1b[B")) | |
| 441 | + | require.Eventually(t, func() bool { | |
| 442 | + | key, ok := controller.CursorKey() | |
| 443 | + | return ok && key == betaKey | |
| 444 | + | }, time.Second, time.Millisecond, "the picker consumed the terminal read") | |
| 445 | + | ||
| 446 | + | cancel() | |
| 447 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |
| 448 | + | require.Empty(t, terminal.out.Bytes(), "the picker never wrote a session or terminal byte") | |
| 449 | + | } | |
| 450 | + | ||
| 451 | + | // TestSupervisorPickerFoldsLaterPublication covers the second-and-later | |
| 452 | + | // publication: a snapshot delivered after the connection is established must | |
| 453 | + | // reach the picker through the subscription awaitLoss parks on. This is the | |
| 454 | + | // core of P5.2b and the fake hub's non-blocking, never-replaced channel is what | |
| 455 | + | // keeps it from busy-spinning. | |
| 456 | + | func TestSupervisorPickerFoldsLaterPublication(t *testing.T) { | |
| 457 | + | ctx, cancel := context.WithCancel(context.Background()) | |
| 458 | + | defer cancel() | |
| 459 | + | ||
| 460 | + | clock := newSupervisorTestClock() | |
| 461 | + | reader := newPickerChunkReader() | |
| 462 | + | t.Cleanup(reader.close) | |
| 463 | + | terminal := &pickerRecordingTerminal{in: reader} | |
| 464 | + | ||
| 465 | + | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |
| 466 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |
| 467 | + | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |
| 468 | + | return service, nil | |
| 469 | + | }) | |
| 470 | + | controller := newPickerController(clock, pickerTestFreshness) | |
| 471 | + | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | |
| 472 | + | ||
| 473 | + | runErr := make(chan error, 1) | |
| 474 | + | go func() { runErr <- sup.Run(ctx) }() | |
| 475 | + | ||
| 476 | + | require.Eventually(t, func() bool { | |
| 477 | + | return controller.Catalogue().Revision() == 1 | |
| 478 | + | }, time.Second, time.Millisecond, "the first committed publication reaches the picker") | |
| 479 | + | require.Equal(t, []string{"alpha"}, pickerSessionLabels(controller.Catalogue().Lines())) | |
| 480 | + | ||
| 481 | + | service.publishSnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta")) | |
| 482 | + | require.Eventually(t, func() bool { | |
| 483 | + | return controller.Catalogue().Revision() == 2 | |
| 484 | + | }, time.Second, time.Millisecond, "a publication delivered after the connection is established reaches the picker") | |
| 485 | + | require.Equal(t, []string{"alpha", "beta"}, pickerSessionLabels(controller.Catalogue().Lines())) | |
| 486 | + | ||
| 487 | + | cancel() | |
| 488 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |
| 489 | + | } | |
| 490 | + | ||
| 491 | + | // TestPickerControllerResolveKeyUsesCommittedRow proves commit and resolve are | |
| 492 | + | // one action: the key captured with the commit decision resolves the committed | |
| 493 | + | // row even when a concurrent publication moved the cursor afterwards. | |
| 494 | + | func TestPickerControllerResolveKeyUsesCommittedRow(t *testing.T) { | |
| 495 | + | controller, clock := pickerTestController(t) | |
| 496 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |
| 497 | + | alphaKey := pickerRowKeyByLabel(t, controller, "alpha") | |
| 498 | + | ||
| 499 | + | // The user commits the row under the cursor. | |
| 500 | + | require.True(t, controller.ConsumeTerminalRead([]byte("\r"))) | |
| 501 | + | op, committedKey := controller.TakeOp() | |
| 502 | + | require.True(t, op.commit) | |
| 503 | + | require.Equal(t, alphaKey, committedKey) | |
| 504 | + | ||
| 505 | + | // A concurrent publication moves the cursor to another row, and the driver | |
| 506 | + | // commits a row different from the one the user selected. | |
| 507 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta", "gamma")) | |
| 508 | + | require.True(t, controller.ConsumeTerminalRead([]byte("j"))) | |
| 509 | + | ||
| 510 | + | // Plain Resolve describes the row displayed now... | |
| 511 | + | displayed, err := controller.Resolve(pickerTestBase()) | |
| 512 | + | require.NoError(t, err) | |
| 513 | + | require.Equal(t, "beta", displayed.Target.SessionName) | |
| 514 | + | ||
| 515 | + | // ...while the captured key resolves exactly the committed row. | |
| 516 | + | committed, err := controller.ResolveKey(committedKey, pickerTestBase()) | |
| 517 | + | require.NoError(t, err) | |
| 518 | + | require.Equal(t, "alpha", committed.Target.SessionName) | |
| 519 | + | } | |
| 520 | + | ||
| 521 | + | // TestPickerControllerCommittedKeySurvivesConcurrentApply runs the commit path | |
| 522 | + | // under -race against a driver that keeps republishing. The captured key must | |
| 523 | + | // always resolve the row the user committed, never the row a publication left | |
| 524 | + | // under the cursor. | |
| 525 | + | func TestPickerControllerCommittedKeySurvivesConcurrentApply(t *testing.T) { | |
| 526 | + | controller, clock := pickerTestController(t) | |
| 527 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |
| 528 | + | ||
| 529 | + | stop := make(chan struct{}) | |
| 530 | + | var applying sync.WaitGroup | |
| 531 | + | applying.Add(1) | |
| 532 | + | go func() { | |
| 533 | + | defer applying.Done() | |
| 534 | + | revision := ports.BrokerRevision(2) | |
| 535 | + | for { | |
| 536 | + | select { | |
| 537 | + | case <-stop: | |
| 538 | + | return | |
| 539 | + | default: | |
| 540 | + | } | |
| 541 | + | controller.ApplySnapshot(pickerTestSnapshot(3, revision, clock.Now(), "alpha", "beta")) | |
| 542 | + | revision++ | |
| 543 | + | } | |
| 544 | + | }() | |
| 545 | + | ||
| 546 | + | for range 200 { | |
| 547 | + | require.True(t, controller.ConsumeTerminalRead([]byte("\r"))) | |
| 548 | + | op, key := controller.TakeOp() | |
| 549 | + | if !op.commit || key == "" { | |
| 550 | + | continue | |
| 551 | + | } | |
| 552 | + | request, err := controller.ResolveKey(key, pickerTestBase()) | |
| 553 | + | if err != nil { | |
| 554 | + | continue | |
| 555 | + | } | |
| 556 | + | require.Equal(t, "alpha", request.Target.SessionName, "the committed key must never resolve another row") | |
| 557 | + | } | |
| 558 | + | close(stop) | |
| 559 | + | applying.Wait() | |
| 560 | + | } | |
| 561 | + | ||
| 562 | + | // TestPickerControllerApplySnapshotRejectsInvalidSnapshot proves the controller | |
| 563 | + | // keeps the previous projection and surfaces a bounded notice when a publication | |
| 564 | + | // fails the broker snapshot contract. | |
| 565 | + | func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) { | |
| 566 | + | controller, clock := pickerTestController(t) | |
| 567 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |
| 568 | + | before := controller.Catalogue().Lines() | |
| 569 | + | ||
| 570 | + | invalid := pickerTestSnapshot(3, 2, clock.Now(), "beta") | |
| 571 | + | invalid.Daemons[0].Availability = 0 | |
| 572 | + | controller.ApplySnapshot(invalid) | |
| 573 | + | ||
| 574 | + | require.Equal(t, before, controller.Catalogue().Lines(), "the previous projection is kept") | |
| 575 | + | require.Equal(t, ports.BrokerRevision(1), controller.Catalogue().Revision()) | |
| 576 | + | ||
| 577 | + | controller.mu.Lock() | |
| 578 | + | active := controller.notices.Active(clock.Now()) | |
| 579 | + | controller.mu.Unlock() | |
| 580 | + | require.Len(t, active, 1) | |
| 581 | + | require.Equal(t, "broker catalogue update rejected", active[0].Message) | |
| 582 | + | require.NotContains(t, active[0].Message, "user@") | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the | |
| 586 | + | // availability-first classification at the controller boundary: an unobserved | |
| 587 | + | // daemon (Availability Unknown, ProtocolVersion 0) surfaces a refreshing | |
| 588 | + | // notice, never a version_mismatch. | |
| 589 | + | func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) { | |
| 590 | + | controller, clock := pickerTestController(t) | |
| 591 | + | unobserved := pickerTestUnobservedObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | |
| 592 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}}) | |
| 593 | + | ||
| 594 | + | controller.mu.Lock() | |
| 595 | + | active := controller.notices.Active(clock.Now()) | |
| 596 | + | controller.mu.Unlock() | |
| 597 | + | require.Len(t, active, 1) | |
| 598 | + | require.Equal(t, "local: "+domain.RemoteReasonRefreshing, active[0].Message) | |
| 599 | + | require.NotContains(t, active[0].Message, domain.RemoteReasonVersionMismatch) | |
| 600 | + | } | |
| 601 | + | ||
| 602 | + | // TestPickerControllerRenderNoticeSelectsNewest pins that the notice shown is | |
| 603 | + | // the newest by ShownAt (tie-broken by ID), independent of the toast manager's | |
| 604 | + | // map-iteration order. | |
| 605 | + | func TestPickerControllerRenderNoticeSelectsNewest(t *testing.T) { | |
| 606 | + | controller, _ := pickerTestController(t) | |
| 607 | + | size := domain.Size{Cols: 80, Rows: 24} | |
| 608 | + | ||
| 609 | + | controller.mu.Lock() | |
| 610 | + | controller.notices.Show(time.Unix(20, 0), ui.Toast{ID: "zzz-older", Message: "older-message"}) | |
| 611 | + | controller.notices.Show(time.Unix(30, 0), ui.Toast{ID: "aaa-newer", Message: "newer-message"}) | |
| 612 | + | controller.mu.Unlock() | |
| 613 | + | require.Contains(t, string(controller.RenderNotice(size)), "newer-message") | |
| 614 | + | ||
| 615 | + | // Equal ShownAt: the lexicographically greatest ID wins, deterministically. | |
| 616 | + | controller.mu.Lock() | |
| 617 | + | controller.notices.Clear() | |
| 618 | + | controller.notices.Show(time.Unix(40, 0), ui.Toast{ID: "aaa", Message: "from-aaa"}) | |
| 619 | + | controller.notices.Show(time.Unix(40, 0), ui.Toast{ID: "bbb", Message: "from-bbb"}) | |
| 620 | + | controller.mu.Unlock() | |
| 621 | + | for range 10 { | |
| 622 | + | require.Contains(t, string(controller.RenderNotice(size)), "from-bbb") | |
| 623 | + | } | |
| 624 | + | } | |
| @@ -0,0 +1,624 @@ | |||||
| 1 | + | package client | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "context" | |||
| 6 | + | "io" | |||
| 7 | + | "sync" | |||
| 8 | + | "testing" | |||
| 9 | + | "time" | |||
| 10 | + | ||||
| 11 | + | "github.com/stretchr/testify/require" | |||
| 12 | + | ||||
| 13 | + | "github.com/bnema/vev/internal/domain" | |||
| 14 | + | "github.com/bnema/vev/internal/ports" | |||
| 15 | + | "github.com/bnema/vev/internal/protocol/catalogue" | |||
| 16 | + | "github.com/bnema/vev/internal/usecase/ui" | |||
| 17 | + | ) | |||
| 18 | + | ||||
| 19 | + | // Client-owned picker controller tests (Plan 001 P5.2b): rendering, local | |||
| 20 | + | // search/cursor behaviour across live updates, and the input-ownership proof | |||
| 21 | + | // that picker input never reaches a session. | |||
| 22 | + | ||||
| 23 | + | // pickerChunkReader yields pre-pushed chunks to the supervisor's single input | |||
| 24 | + | // lifetime. push blocks until the lifetime reads the chunk, so a test can | |||
| 25 | + | // order a read deterministically without a sleep. | |||
| 26 | + | type pickerChunkReader struct { | |||
| 27 | + | chunks chan []byte | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func newPickerChunkReader() *pickerChunkReader { | |||
| 31 | + | return &pickerChunkReader{chunks: make(chan []byte)} | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func (r *pickerChunkReader) push(data []byte) { | |||
| 35 | + | r.chunks <- append([]byte(nil), data...) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func (r *pickerChunkReader) close() { close(r.chunks) } | |||
| 39 | + | ||||
| 40 | + | func (r *pickerChunkReader) Read(buffer []byte) (int, error) { | |||
| 41 | + | chunk, ok := <-r.chunks | |||
| 42 | + | if !ok { | |||
| 43 | + | return 0, io.EOF | |||
| 44 | + | } | |||
| 45 | + | return copy(buffer, chunk), nil | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | // pickerInputSpy forwards each read to the real consumer and signals that it | |||
| 49 | + | // was delivered, so a test can wait exactly for consumption. | |||
| 50 | + | type pickerInputSpy struct { | |||
| 51 | + | inner pickerInputConsumer | |||
| 52 | + | done chan struct{} | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func newPickerInputSpy(inner pickerInputConsumer) *pickerInputSpy { | |||
| 56 | + | return &pickerInputSpy{inner: inner, done: make(chan struct{}, 1)} | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | func (s *pickerInputSpy) ConsumeTerminalRead(data []byte) bool { | |||
| 60 | + | consumed := s.inner.ConsumeTerminalRead(data) | |||
| 61 | + | select { | |||
| 62 | + | case s.done <- struct{}{}: | |||
| 63 | + | default: | |||
| 64 | + | } | |||
| 65 | + | return consumed | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | func (s *pickerInputSpy) await(t *testing.T) { | |||
| 69 | + | t.Helper() | |||
| 70 | + | select { | |||
| 71 | + | case <-s.done: | |||
| 72 | + | case <-time.After(5 * time.Second): | |||
| 73 | + | t.Fatal("terminal input was not consumed") | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // pickerRecordingTerminal records every byte written to the terminal output, | |||
| 78 | + | // which is where a leaked picker key would surface if it reached a session. | |||
| 79 | + | type pickerRecordingTerminal struct { | |||
| 80 | + | in io.Reader | |||
| 81 | + | out bytes.Buffer | |||
| 82 | + | mu sync.Mutex | |||
| 83 | + | enters int | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func (t *pickerRecordingTerminal) EnterRaw() (func() error, error) { | |||
| 87 | + | t.mu.Lock() | |||
| 88 | + | t.enters++ | |||
| 89 | + | t.mu.Unlock() | |||
| 90 | + | return func() error { return nil }, nil | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func (t *pickerRecordingTerminal) Geometry() (domain.Geometry, error) { | |||
| 94 | + | return domain.Geometry{Size: domain.Size{Cols: 80, Rows: 24}}, nil | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (t *pickerRecordingTerminal) ResizeEvents() <-chan domain.Geometry { return nil } | |||
| 98 | + | ||||
| 99 | + | func (t *pickerRecordingTerminal) In() io.Reader { return t.in } | |||
| 100 | + | ||||
| 101 | + | func (t *pickerRecordingTerminal) Out() io.Writer { return &t.out } | |||
| 102 | + | ||||
| 103 | + | func (t *pickerRecordingTerminal) Flush() error { return nil } | |||
| 104 | + | ||||
| 105 | + | func pickerTestSnapshot(epoch ports.BrokerEpoch, revision ports.BrokerRevision, now time.Time, sessions ...string) ports.BrokerSnapshot { | |||
| 106 | + | catalogSessions := make([]catalogue.RemoteCatalogSession, 0, len(sessions)) | |||
| 107 | + | for i, name := range sessions { | |||
| 108 | + | catalogSessions = append(catalogSessions, pickerTestSession(name, byte(i+1), catalogue_Up)) | |||
| 109 | + | } | |||
| 110 | + | return ports.BrokerSnapshot{Epoch: epoch, Revision: revision, Daemons: []ports.BrokerDaemonObservation{ | |||
| 111 | + | pickerTestLocalObservation(now, catalogSessions...), | |||
| 112 | + | }} | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | func pickerTestController(t *testing.T) (*pickerController, *supervisorTestClock) { | |||
| 116 | + | t.Helper() | |||
| 117 | + | clock := newSupervisorTestClock() | |||
| 118 | + | return newPickerController(clock, pickerTestFreshness), clock | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | func pickerRowKeyByLabel(t *testing.T, controller *pickerController, label string) string { | |||
| 122 | + | t.Helper() | |||
| 123 | + | line, ok := pickerLineByLabel(controller.Catalogue().Lines(), label) | |||
| 124 | + | require.True(t, ok, "row %q must be projected", label) | |||
| 125 | + | return line.Key | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func mustCursorKey(t *testing.T, controller *pickerController) string { | |||
| 129 | + | t.Helper() | |||
| 130 | + | key, ok := controller.CursorKey() | |||
| 131 | + | require.True(t, ok, "the picker must have a selected row") | |||
| 132 | + | return key | |||
| 133 | + | } | |||
| 134 | + | ||||
| 135 | + | func (p *pickerController) modelQuery(t *testing.T) string { | |||
| 136 | + | t.Helper() | |||
| 137 | + | p.mu.Lock() | |||
| 138 | + | defer p.mu.Unlock() | |||
| 139 | + | require.NotNil(t, p.loop) | |||
| 140 | + | return p.loop.model.Query() | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | func (p *pickerController) modelSearchActive(t *testing.T) bool { | |||
| 144 | + | t.Helper() | |||
| 145 | + | p.mu.Lock() | |||
| 146 | + | defer p.mu.Unlock() | |||
| 147 | + | require.NotNil(t, p.loop) | |||
| 148 | + | return p.loop.model.SearchActive() | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestPickerControllerRendersLatestSnapshotImmediately(t *testing.T) { | |||
| 152 | + | controller, clock := pickerTestController(t) | |||
| 153 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |||
| 154 | + | firstRender := controller.Render(domain.Size{Cols: 80, Rows: 24}) | |||
| 155 | + | require.NotEmpty(t, firstRender) | |||
| 156 | + | alphaKey := pickerRowKeyByLabel(t, controller, "alpha") | |||
| 157 | + | require.Equal(t, alphaKey, mustCursorKey(t, controller)) | |||
| 158 | + | ||||
| 159 | + | // A newer publication is folded synchronously: the very next render already | |||
| 160 | + | // reflects it, with no debounce or intervening frame. | |||
| 161 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "beta")) | |||
| 162 | + | require.Equal(t, pickerRowKeyByLabel(t, controller, "beta"), mustCursorKey(t, controller)) | |||
| 163 | + | require.NotEmpty(t, controller.Render(domain.Size{Cols: 80, Rows: 24})) | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func TestPickerControllerSearchSurvivesHostUpdate(t *testing.T) { | |||
| 167 | + | controller, clock := pickerTestController(t) | |||
| 168 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |||
| 169 | + | ||||
| 170 | + | spy := newPickerInputSpy(controller) | |||
| 171 | + | reader := newPickerChunkReader() | |||
| 172 | + | t.Cleanup(reader.close) | |||
| 173 | + | _ = startTerminalInputLifetime(reader, spy) | |||
| 174 | + | ||||
| 175 | + | reader.push([]byte("/alp")) | |||
| 176 | + | spy.await(t) | |||
| 177 | + | require.Equal(t, "alp", controller.modelQuery(t)) | |||
| 178 | + | require.True(t, controller.modelSearchActive(t)) | |||
| 179 | + | ||||
| 180 | + | // A host update while the query is typed keeps the editor and the matched | |||
| 181 | + | // cursor; it never resets the local search. | |||
| 182 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta", "gamma")) | |||
| 183 | + | require.Equal(t, "alp", controller.modelQuery(t)) | |||
| 184 | + | require.True(t, controller.modelSearchActive(t)) | |||
| 185 | + | require.Equal(t, pickerRowKeyByLabel(t, controller, "alpha"), mustCursorKey(t, controller)) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | // TestPickerControllerInputNeverReachesPTY feeds every input shape a picker can | |||
| 189 | + | // receive and proves the only effects are local: each batch is consumed, the | |||
| 190 | + | // terminal receives no bytes, and the decisions are presentation facts, never a | |||
| 191 | + | // session write. | |||
| 192 | + | func TestPickerControllerInputNeverReachesPTY(t *testing.T) { | |||
| 193 | + | controller, clock := pickerTestController(t) | |||
| 194 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |||
| 195 | + | ||||
| 196 | + | alphaKey := pickerRowKeyByLabel(t, controller, "alpha") | |||
| 197 | + | betaKey := pickerRowKeyByLabel(t, controller, "beta") | |||
| 198 | + | ||||
| 199 | + | spy := newPickerInputSpy(controller) | |||
| 200 | + | reader := newPickerChunkReader() | |||
| 201 | + | t.Cleanup(reader.close) | |||
| 202 | + | terminal := &pickerRecordingTerminal{in: reader} | |||
| 203 | + | _ = startTerminalInputLifetime(terminal.In(), spy) | |||
| 204 | + | ||||
| 205 | + | chunks := [][]byte{ | |||
| 206 | + | []byte("\x1b[B"), // Down -> beta | |||
| 207 | + | []byte("k"), // up -> alpha | |||
| 208 | + | []byte("j"), // down -> beta | |||
| 209 | + | []byte("\x1b[A"), // Up -> alpha | |||
| 210 | + | []byte("\x1b[<0;10;5M"), // mouse report: consumed and dropped | |||
| 211 | + | []byte("\x1b[200~rm -rf /\x1b[201~"), // bracketed paste: content dropped | |||
| 212 | + | []byte("\x00\x01\x7f"), // control bytes: consumed and dropped | |||
| 213 | + | []byte("\x1b"), // lone ESC withheld for disambiguation | |||
| 214 | + | } | |||
| 215 | + | for _, chunk := range chunks { | |||
| 216 | + | reader.push(chunk) | |||
| 217 | + | spy.await(t) | |||
| 218 | + | } | |||
| 219 | + | require.Equal(t, alphaKey, mustCursorKey(t, controller), "arrow keys moved the local cursor") | |||
| 220 | + | require.False(t, controller.modelSearchActive(t), "mouse, paste, and control bytes never opened search") | |||
| 221 | + | require.Equal(t, betaKey, pickerRowKeyByLabel(t, controller, "beta")) | |||
| 222 | + | ||||
| 223 | + | // A withheld lone ESC resolves through the bounded window as one Escape. | |||
| 224 | + | controller.FlushPending() | |||
| 225 | + | op, _ := controller.TakeOp() | |||
| 226 | + | require.True(t, op.close, "Escape retires the picker locally") | |||
| 227 | + | ||||
| 228 | + | require.Empty(t, terminal.out.Bytes(), "the picker wrote nothing to the terminal") | |||
| 229 | + | } | |||
| 230 | + | ||||
| 231 | + | func TestPickerControllerInputRouting(t *testing.T) { | |||
| 232 | + | tests := []struct { | |||
| 233 | + | name string | |||
| 234 | + | chunk []byte | |||
| 235 | + | flush bool | |||
| 236 | + | wantCursor string | |||
| 237 | + | wantNoSelection bool | |||
| 238 | + | wantSearch bool | |||
| 239 | + | wantQuery string | |||
| 240 | + | wantOp pickerOp | |||
| 241 | + | }{ | |||
| 242 | + | {name: "down", chunk: []byte("\x1b[B"), wantCursor: "beta"}, | |||
| 243 | + | {name: "up rests on the host row", chunk: []byte("\x1b[A"), wantNoSelection: true}, | |||
| 244 | + | {name: "search entry", chunk: []byte("/"), wantCursor: "alpha", wantSearch: true}, | |||
| 245 | + | {name: "search text", chunk: []byte("/al"), wantCursor: "alpha", wantSearch: true, wantQuery: "al"}, | |||
| 246 | + | {name: "enter commits", chunk: []byte("/al\r"), wantCursor: "alpha", wantSearch: true, wantQuery: "al", wantOp: pickerOp{commit: true}}, | |||
| 247 | + | {name: "kill", chunk: []byte("x"), wantCursor: "alpha", wantOp: pickerOp{kill: true}}, | |||
| 248 | + | {name: "escape exits search first", chunk: []byte("/al\x1b"), flush: true, wantCursor: "alpha"}, | |||
| 249 | + | {name: "escape closes", chunk: []byte("\x1b"), flush: true, wantCursor: "alpha", wantOp: pickerOp{close: true}}, | |||
| 250 | + | {name: "f1 is consumed", chunk: []byte("\x1b[11~"), wantCursor: "alpha"}, | |||
| 251 | + | } | |||
| 252 | + | ||||
| 253 | + | for _, tt := range tests { | |||
| 254 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 255 | + | controller, clock := pickerTestController(t) | |||
| 256 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |||
| 257 | + | alphaKey := pickerRowKeyByLabel(t, controller, "alpha") | |||
| 258 | + | spy := newPickerInputSpy(controller) | |||
| 259 | + | reader := newPickerChunkReader() | |||
| 260 | + | t.Cleanup(reader.close) | |||
| 261 | + | _ = startTerminalInputLifetime(reader, spy) | |||
| 262 | + | ||||
| 263 | + | reader.push(tt.chunk) | |||
| 264 | + | spy.await(t) | |||
| 265 | + | if tt.flush { | |||
| 266 | + | controller.FlushPending() | |||
| 267 | + | } | |||
| 268 | + | if tt.wantOp != (pickerOp{}) { | |||
| 269 | + | op, _ := controller.TakeOp() | |||
| 270 | + | require.Equal(t, tt.wantOp, op) | |||
| 271 | + | } | |||
| 272 | + | require.Equal(t, tt.wantSearch, controller.modelSearchActive(t)) | |||
| 273 | + | if tt.wantQuery != "" { | |||
| 274 | + | require.Equal(t, tt.wantQuery, controller.modelQuery(t)) | |||
| 275 | + | } | |||
| 276 | + | if tt.wantNoSelection { | |||
| 277 | + | _, ok := controller.CursorKey() | |||
| 278 | + | require.False(t, ok, "the host row is a cursor destination but not a selection") | |||
| 279 | + | return | |||
| 280 | + | } | |||
| 281 | + | want := alphaKey | |||
| 282 | + | if tt.wantCursor == "beta" { | |||
| 283 | + | want = pickerRowKeyByLabel(t, controller, "beta") | |||
| 284 | + | } | |||
| 285 | + | require.Equal(t, want, mustCursorKey(t, controller)) | |||
| 286 | + | }) | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | // TestPickerControllerNotOwningInputLeavesReadUnconsumed pins the ownership | |||
| 291 | + | // boundary: once the picker releases input, a read is not consumed, so a later | |||
| 292 | + | // attach slice keeps its ordinary pipeline. | |||
| 293 | + | func TestPickerControllerNotOwningInputLeavesReadUnconsumed(t *testing.T) { | |||
| 294 | + | controller, clock := pickerTestController(t) | |||
| 295 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |||
| 296 | + | controller.SetOwnsInput(false) | |||
| 297 | + | require.False(t, controller.ConsumeTerminalRead([]byte("j"))) | |||
| 298 | + | controller.SetOwnsInput(true) | |||
| 299 | + | require.True(t, controller.ConsumeTerminalRead([]byte("j"))) | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | func TestPickerControllerSurfacesProgressAndFailuresAsNotices(t *testing.T) { | |||
| 303 | + | controller, clock := pickerTestController(t) | |||
| 304 | + | checking := pickerTestLocalObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 305 | + | checking.Checking = true | |||
| 306 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{checking}}) | |||
| 307 | + | require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "a refreshing host is surfaced as progress") | |||
| 308 | + | ||||
| 309 | + | unreachable := pickerTestLocalObservation(clock.Now()) | |||
| 310 | + | unreachable.Availability = domain.RemoteAvailabilityUnreachable | |||
| 311 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2, Daemons: []ports.BrokerDaemonObservation{unreachable}}) | |||
| 312 | + | require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "an unavailable host is surfaced as a failure") | |||
| 313 | + | } | |||
| 314 | + | ||||
| 315 | + | // TestPickerControllerResolveRefusesInspectableRow: a row the picker displays | |||
| 316 | + | // for inspection (a stale host) still resolves to a typed refusal, so the user | |||
| 317 | + | // never gets a generic failure for a known cause. | |||
| 318 | + | func TestPickerControllerResolveRefusesInspectableRow(t *testing.T) { | |||
| 319 | + | controller, clock := pickerTestController(t) | |||
| 320 | + | stale := pickerTestLocalObservation(clock.Now().Add(-2*pickerTestFreshness), pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 321 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{stale}}) | |||
| 322 | + | ||||
| 323 | + | // The cursor first rests on the host status row, then reaches the stale | |||
| 324 | + | // session row; neither admits an action. | |||
| 325 | + | _, ok := controller.CursorKey() | |||
| 326 | + | require.False(t, ok) | |||
| 327 | + | controller.ConsumeTerminalRead([]byte("j")) | |||
| 328 | + | require.False(t, controller.modelSearchActive(t)) | |||
| 329 | + | ||||
| 330 | + | _, err := controller.Resolve(pickerTestBase()) | |||
| 331 | + | require.Error(t, err) | |||
| 332 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueStale), "a stale row refuses with its exact cause") | |||
| 333 | + | require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24})) | |||
| 334 | + | } | |||
| 335 | + | ||||
| 336 | + | // TestPickerControllerResolveUsesLatestSnapshot proves resolution revalidates | |||
| 337 | + | // against the snapshot in force at resolve time, not the one displayed. | |||
| 338 | + | func TestPickerControllerResolveUsesLatestSnapshot(t *testing.T) { | |||
| 339 | + | controller, clock := pickerTestController(t) | |||
| 340 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |||
| 341 | + | key := mustCursorKey(t, controller) | |||
| 342 | + | ||||
| 343 | + | // The host disappears before the selection resolves. | |||
| 344 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 2}) | |||
| 345 | + | ||||
| 346 | + | _, err := controller.Resolve(pickerTestBase()) | |||
| 347 | + | require.Error(t, err) | |||
| 348 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueNoSelection), "the vanished row leaves no selection") | |||
| 349 | + | _, err = controller.Catalogue().Resolve(key, pickerTestBase()) | |||
| 350 | + | require.Error(t, err) | |||
| 351 | + | require.True(t, pickerCatalogueErrorIs(err, pickerCatalogueGone)) | |||
| 352 | + | require.NotEmpty(t, controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}), "the refusal is surfaced as a bounded notice") | |||
| 353 | + | } | |||
| 354 | + | ||||
| 355 | + | func TestPickerControllerResolveReturnsExactRequest(t *testing.T) { | |||
| 356 | + | controller, clock := pickerTestController(t) | |||
| 357 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |||
| 358 | + | ||||
| 359 | + | request, err := controller.Resolve(pickerTestBase()) | |||
| 360 | + | require.NoError(t, err) | |||
| 361 | + | require.NoError(t, request.Validate()) | |||
| 362 | + | require.Equal(t, ports.BrokerAdmissionExact, request.Admission) | |||
| 363 | + | require.Equal(t, ports.BrokerStreamAttachment, request.Purpose) | |||
| 364 | + | require.Equal(t, "alpha", request.Target.SessionName) | |||
| 365 | + | } | |||
| 366 | + | ||||
| 367 | + | // TestPickerControllerConcurrentUpdatesAndInput exercises the mutex boundaries | |||
| 368 | + | // under -race: publications, input consumption, rendering, and resolution run | |||
| 369 | + | // concurrently and must not race or panic. | |||
| 370 | + | func TestPickerControllerConcurrentUpdatesAndInput(t *testing.T) { | |||
| 371 | + | const iterations = 200 | |||
| 372 | + | controller, clock := pickerTestController(t) | |||
| 373 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |||
| 374 | + | ||||
| 375 | + | var wait sync.WaitGroup | |||
| 376 | + | wait.Add(3) | |||
| 377 | + | ||||
| 378 | + | go func() { | |||
| 379 | + | defer wait.Done() | |||
| 380 | + | for revision := ports.BrokerRevision(2); revision < 2+iterations; revision++ { | |||
| 381 | + | controller.ApplySnapshot(pickerTestSnapshot(3, revision, clock.Now(), "alpha", "beta")) | |||
| 382 | + | } | |||
| 383 | + | }() | |||
| 384 | + | ||||
| 385 | + | go func() { | |||
| 386 | + | defer wait.Done() | |||
| 387 | + | for range iterations { | |||
| 388 | + | controller.ConsumeTerminalRead([]byte("\x1b[B\x1b[Aj")) | |||
| 389 | + | controller.FlushPending() | |||
| 390 | + | controller.TakeOp() | |||
| 391 | + | } | |||
| 392 | + | }() | |||
| 393 | + | ||||
| 394 | + | go func() { | |||
| 395 | + | defer wait.Done() | |||
| 396 | + | for range iterations { | |||
| 397 | + | _ = controller.Render(domain.Size{Cols: 80, Rows: 24}) | |||
| 398 | + | _ = controller.RenderNotice(domain.Size{Cols: 80, Rows: 24}) | |||
| 399 | + | _, _ = controller.Resolve(pickerTestBase()) | |||
| 400 | + | _, _ = controller.CursorKey() | |||
| 401 | + | } | |||
| 402 | + | }() | |||
| 403 | + | ||||
| 404 | + | wait.Wait() | |||
| 405 | + | } | |||
| 406 | + | ||||
| 407 | + | // TestSupervisorPickerFoldsPublicationsAndInput proves the supervisor wiring: | |||
| 408 | + | // a broker publication reaches the picker through the established connection's | |||
| 409 | + | // subscription, and terminal input is consumed by the picker from the same | |||
| 410 | + | // single input lifetime the supervisor uses for EOF, never written to the | |||
| 411 | + | // terminal. | |||
| 412 | + | func TestSupervisorPickerFoldsPublicationsAndInput(t *testing.T) { | |||
| 413 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 414 | + | defer cancel() | |||
| 415 | + | ||||
| 416 | + | clock := newSupervisorTestClock() | |||
| 417 | + | reader := newPickerChunkReader() | |||
| 418 | + | t.Cleanup(reader.close) | |||
| 419 | + | terminal := &pickerRecordingTerminal{in: reader} | |||
| 420 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |||
| 421 | + | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |||
| 422 | + | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |||
| 423 | + | return service, nil | |||
| 424 | + | }) | |||
| 425 | + | controller := newPickerController(clock, pickerTestFreshness) | |||
| 426 | + | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | |||
| 427 | + | ||||
| 428 | + | runErr := make(chan error, 1) | |||
| 429 | + | go func() { runErr <- sup.Run(ctx) }() | |||
| 430 | + | ||||
| 431 | + | require.Eventually(t, func() bool { | |||
| 432 | + | return controller.Catalogue().Epoch() == 3 | |||
| 433 | + | }, time.Second, time.Millisecond, "the supervisor folds the committed publication into the picker") | |||
| 434 | + | require.Equal(t, []string{"alpha", "beta"}, pickerSessionLabels(controller.Catalogue().Lines())) | |||
| 435 | + | require.True(t, controller.OwnsInput()) | |||
| 436 | + | ||||
| 437 | + | // The same input lifetime the supervisor uses for EOF delivers the read to | |||
| 438 | + | // the picker: the cursor moves locally and nothing is written anywhere. | |||
| 439 | + | betaKey := pickerRowKeyByLabel(t, controller, "beta") | |||
| 440 | + | reader.push([]byte("\x1b[B")) | |||
| 441 | + | require.Eventually(t, func() bool { | |||
| 442 | + | key, ok := controller.CursorKey() | |||
| 443 | + | return ok && key == betaKey | |||
| 444 | + | }, time.Second, time.Millisecond, "the picker consumed the terminal read") | |||
| 445 | + | ||||
| 446 | + | cancel() | |||
| 447 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |||
| 448 | + | require.Empty(t, terminal.out.Bytes(), "the picker never wrote a session or terminal byte") | |||
| 449 | + | } | |||
| 450 | + | ||||
| 451 | + | // TestSupervisorPickerFoldsLaterPublication covers the second-and-later | |||
| 452 | + | // publication: a snapshot delivered after the connection is established must | |||
| 453 | + | // reach the picker through the subscription awaitLoss parks on. This is the | |||
| 454 | + | // core of P5.2b and the fake hub's non-blocking, never-replaced channel is what | |||
| 455 | + | // keeps it from busy-spinning. | |||
| 456 | + | func TestSupervisorPickerFoldsLaterPublication(t *testing.T) { | |||
| 457 | + | ctx, cancel := context.WithCancel(context.Background()) | |||
| 458 | + | defer cancel() | |||
| 459 | + | ||||
| 460 | + | clock := newSupervisorTestClock() | |||
| 461 | + | reader := newPickerChunkReader() | |||
| 462 | + | t.Cleanup(reader.close) | |||
| 463 | + | terminal := &pickerRecordingTerminal{in: reader} | |||
| 464 | + | ||||
| 465 | + | service := newSupervisorTestService(ports.BrokerConnectionID{1}) | |||
| 466 | + | connector := newSupervisorTestConnector(func(context.Context, int) (ports.BrokerService, error) { | |||
| 467 | + | service.hub.publish(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |||
| 468 | + | return service, nil | |||
| 469 | + | }) | |||
| 470 | + | controller := newPickerController(clock, pickerTestFreshness) | |||
| 471 | + | sup := mustSupervisor(t, SupervisorConfig{Connector: connector, Terminal: terminal, Clock: clock, Picker: controller}) | |||
| 472 | + | ||||
| 473 | + | runErr := make(chan error, 1) | |||
| 474 | + | go func() { runErr <- sup.Run(ctx) }() | |||
| 475 | + | ||||
| 476 | + | require.Eventually(t, func() bool { | |||
| 477 | + | return controller.Catalogue().Revision() == 1 | |||
| 478 | + | }, time.Second, time.Millisecond, "the first committed publication reaches the picker") | |||
| 479 | + | require.Equal(t, []string{"alpha"}, pickerSessionLabels(controller.Catalogue().Lines())) | |||
| 480 | + | ||||
| 481 | + | service.publishSnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta")) | |||
| 482 | + | require.Eventually(t, func() bool { | |||
| 483 | + | return controller.Catalogue().Revision() == 2 | |||
| 484 | + | }, time.Second, time.Millisecond, "a publication delivered after the connection is established reaches the picker") | |||
| 485 | + | require.Equal(t, []string{"alpha", "beta"}, pickerSessionLabels(controller.Catalogue().Lines())) | |||
| 486 | + | ||||
| 487 | + | cancel() | |||
| 488 | + | require.ErrorIs(t, <-runErr, context.Canceled) | |||
| 489 | + | } | |||
| 490 | + | ||||
| 491 | + | // TestPickerControllerResolveKeyUsesCommittedRow proves commit and resolve are | |||
| 492 | + | // one action: the key captured with the commit decision resolves the committed | |||
| 493 | + | // row even when a concurrent publication moved the cursor afterwards. | |||
| 494 | + | func TestPickerControllerResolveKeyUsesCommittedRow(t *testing.T) { | |||
| 495 | + | controller, clock := pickerTestController(t) | |||
| 496 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |||
| 497 | + | alphaKey := pickerRowKeyByLabel(t, controller, "alpha") | |||
| 498 | + | ||||
| 499 | + | // The user commits the row under the cursor. | |||
| 500 | + | require.True(t, controller.ConsumeTerminalRead([]byte("\r"))) | |||
| 501 | + | op, committedKey := controller.TakeOp() | |||
| 502 | + | require.True(t, op.commit) | |||
| 503 | + | require.Equal(t, alphaKey, committedKey) | |||
| 504 | + | ||||
| 505 | + | // A concurrent publication moves the cursor to another row, and the driver | |||
| 506 | + | // commits a row different from the one the user selected. | |||
| 507 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 2, clock.Now(), "alpha", "beta", "gamma")) | |||
| 508 | + | require.True(t, controller.ConsumeTerminalRead([]byte("j"))) | |||
| 509 | + | ||||
| 510 | + | // Plain Resolve describes the row displayed now... | |||
| 511 | + | displayed, err := controller.Resolve(pickerTestBase()) | |||
| 512 | + | require.NoError(t, err) | |||
| 513 | + | require.Equal(t, "beta", displayed.Target.SessionName) | |||
| 514 | + | ||||
| 515 | + | // ...while the captured key resolves exactly the committed row. | |||
| 516 | + | committed, err := controller.ResolveKey(committedKey, pickerTestBase()) | |||
| 517 | + | require.NoError(t, err) | |||
| 518 | + | require.Equal(t, "alpha", committed.Target.SessionName) | |||
| 519 | + | } | |||
| 520 | + | ||||
| 521 | + | // TestPickerControllerCommittedKeySurvivesConcurrentApply runs the commit path | |||
| 522 | + | // under -race against a driver that keeps republishing. The captured key must | |||
| 523 | + | // always resolve the row the user committed, never the row a publication left | |||
| 524 | + | // under the cursor. | |||
| 525 | + | func TestPickerControllerCommittedKeySurvivesConcurrentApply(t *testing.T) { | |||
| 526 | + | controller, clock := pickerTestController(t) | |||
| 527 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha", "beta")) | |||
| 528 | + | ||||
| 529 | + | stop := make(chan struct{}) | |||
| 530 | + | var applying sync.WaitGroup | |||
| 531 | + | applying.Add(1) | |||
| 532 | + | go func() { | |||
| 533 | + | defer applying.Done() | |||
| 534 | + | revision := ports.BrokerRevision(2) | |||
| 535 | + | for { | |||
| 536 | + | select { | |||
| 537 | + | case <-stop: | |||
| 538 | + | return | |||
| 539 | + | default: | |||
| 540 | + | } | |||
| 541 | + | controller.ApplySnapshot(pickerTestSnapshot(3, revision, clock.Now(), "alpha", "beta")) | |||
| 542 | + | revision++ | |||
| 543 | + | } | |||
| 544 | + | }() | |||
| 545 | + | ||||
| 546 | + | for range 200 { | |||
| 547 | + | require.True(t, controller.ConsumeTerminalRead([]byte("\r"))) | |||
| 548 | + | op, key := controller.TakeOp() | |||
| 549 | + | if !op.commit || key == "" { | |||
| 550 | + | continue | |||
| 551 | + | } | |||
| 552 | + | request, err := controller.ResolveKey(key, pickerTestBase()) | |||
| 553 | + | if err != nil { | |||
| 554 | + | continue | |||
| 555 | + | } | |||
| 556 | + | require.Equal(t, "alpha", request.Target.SessionName, "the committed key must never resolve another row") | |||
| 557 | + | } | |||
| 558 | + | close(stop) | |||
| 559 | + | applying.Wait() | |||
| 560 | + | } | |||
| 561 | + | ||||
| 562 | + | // TestPickerControllerApplySnapshotRejectsInvalidSnapshot proves the controller | |||
| 563 | + | // keeps the previous projection and surfaces a bounded notice when a publication | |||
| 564 | + | // fails the broker snapshot contract. | |||
| 565 | + | func TestPickerControllerApplySnapshotRejectsInvalidSnapshot(t *testing.T) { | |||
| 566 | + | controller, clock := pickerTestController(t) | |||
| 567 | + | controller.ApplySnapshot(pickerTestSnapshot(3, 1, clock.Now(), "alpha")) | |||
| 568 | + | before := controller.Catalogue().Lines() | |||
| 569 | + | ||||
| 570 | + | invalid := pickerTestSnapshot(3, 2, clock.Now(), "beta") | |||
| 571 | + | invalid.Daemons[0].Availability = 0 | |||
| 572 | + | controller.ApplySnapshot(invalid) | |||
| 573 | + | ||||
| 574 | + | require.Equal(t, before, controller.Catalogue().Lines(), "the previous projection is kept") | |||
| 575 | + | require.Equal(t, ports.BrokerRevision(1), controller.Catalogue().Revision()) | |||
| 576 | + | ||||
| 577 | + | controller.mu.Lock() | |||
| 578 | + | active := controller.notices.Active(clock.Now()) | |||
| 579 | + | controller.mu.Unlock() | |||
| 580 | + | require.Len(t, active, 1) | |||
| 581 | + | require.Equal(t, "broker catalogue update rejected", active[0].Message) | |||
| 582 | + | require.NotContains(t, active[0].Message, "user@") | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | // TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch pins the | |||
| 586 | + | // availability-first classification at the controller boundary: an unobserved | |||
| 587 | + | // daemon (Availability Unknown, ProtocolVersion 0) surfaces a refreshing | |||
| 588 | + | // notice, never a version_mismatch. | |||
| 589 | + | func TestPickerControllerUnobservedDaemonIsRefreshingNotVersionMismatch(t *testing.T) { | |||
| 590 | + | controller, clock := pickerTestController(t) | |||
| 591 | + | unobserved := pickerTestUnobservedObservation(clock.Now(), pickerTestSession("alpha", 1, catalogue_Up)) | |||
| 592 | + | controller.ApplySnapshot(ports.BrokerSnapshot{Epoch: 3, Revision: 1, Daemons: []ports.BrokerDaemonObservation{unobserved}}) | |||
| 593 | + | ||||
| 594 | + | controller.mu.Lock() | |||
| 595 | + | active := controller.notices.Active(clock.Now()) | |||
| 596 | + | controller.mu.Unlock() | |||
| 597 | + | require.Len(t, active, 1) | |||
| 598 | + | require.Equal(t, "local: "+domain.RemoteReasonRefreshing, active[0].Message) | |||
| 599 | + | require.NotContains(t, active[0].Message, domain.RemoteReasonVersionMismatch) | |||
| 600 | + | } | |||
| 601 | + | ||||
| 602 | + | // TestPickerControllerRenderNoticeSelectsNewest pins that the notice shown is | |||
| 603 | + | // the newest by ShownAt (tie-broken by ID), independent of the toast manager's | |||
| 604 | + | // map-iteration order. | |||
| 605 | + | func TestPickerControllerRenderNoticeSelectsNewest(t *testing.T) { | |||
| 606 | + | controller, _ := pickerTestController(t) | |||
| 607 | + | size := domain.Size{Cols: 80, Rows: 24} | |||
| 608 | + | ||||
| 609 | + | controller.mu.Lock() | |||
| 610 | + | controller.notices.Show(time.Unix(20, 0), ui.Toast{ID: "zzz-older", Message: "older-message"}) | |||
| 611 | + | controller.notices.Show(time.Unix(30, 0), ui.Toast{ID: "aaa-newer", Message: "newer-message"}) | |||
| 612 | + | controller.mu.Unlock() | |||
| 613 | + | require.Contains(t, string(controller.RenderNotice(size)), "newer-message") | |||
| 614 | + | ||||
| 615 | + | // Equal ShownAt: the lexicographically greatest ID wins, deterministically. | |||
| 616 | + | controller.mu.Lock() | |||
| 617 | + | controller.notices.Clear() | |||
| 618 | + | controller.notices.Show(time.Unix(40, 0), ui.Toast{ID: "aaa", Message: "from-aaa"}) | |||
| 619 | + | controller.notices.Show(time.Unix(40, 0), ui.Toast{ID: "bbb", Message: "from-bbb"}) | |||
| 620 | + | controller.mu.Unlock() | |||
| 621 | + | for range 10 { | |||
| 622 | + | require.Contains(t, string(controller.RenderNotice(size)), "from-bbb") | |||
| 623 | + | } | |||
| 624 | + | } | |||
M internal/usecase/client/supervisor.go +51 -17
| @@ -216,6 +216,12 @@ type SupervisorConfig struct { | |||
| 216 | 216 | // randomness seam so tests can pin the retry cadence exactly. Optional; the | |
| 217 | 217 | // default is a process-local random source. | |
| 218 | 218 | Jitter func() float64 | |
| 219 | + | // Picker is the client-owned picker (Plan 001 P5.2b). When set, the | |
| 220 | + | // supervisor folds every broker publication into it and hands it every | |
| 221 | + | // terminal read from the same single input lifetime used for EOF detection, | |
| 222 | + | // so the picker never starts a second reader. When nil the supervisor drops | |
| 223 | + | // terminal input exactly as P5.1a does. Optional. | |
| 224 | + | Picker pickerHost | |
| 219 | 225 | } | |
| 220 | 226 | ||
| 221 | 227 | // Supervisor owns one autonomous client process: raw mode, one terminal input | |
| @@ -243,6 +249,11 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) { | |||
| 243 | 249 | if cfg.Render == nil { | |
| 244 | 250 | cfg.Render = func(State) {} | |
| 245 | 251 | } | |
| 252 | + | if supervisorNil(cfg.Picker) { | |
| 253 | + | // Normalize a typed-nil picker so Run's nil check and the input | |
| 254 | + | // lifetime's consumer check are both safe. | |
| 255 | + | cfg.Picker = nil | |
| 256 | + | } | |
| 246 | 257 | if cfg.Jitter == nil { | |
| 247 | 258 | cfg.Jitter = rand.Float64 | |
| 248 | 259 | } | |
| @@ -279,7 +290,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 279 | 290 | } | |
| 280 | 291 | }() | |
| 281 | 292 | ||
| 282 | - | input := startTerminalInputLifetime(s.cfg.Terminal.In()) | |
| 293 | + | input := startTerminalInputLifetime(s.cfg.Terminal.In(), s.cfg.Picker) | |
| 283 | 294 | ||
| 284 | 295 | // The active connection is owned across loop iterations. retire is | |
| 285 | 296 | // idempotent so every exit path closes exactly the connection it adopted. | |
| @@ -356,9 +367,15 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||
| 356 | 367 | } | |
| 357 | 368 | ||
| 358 | 369 | service, sub = result.service, result.sub | |
| 370 | + | if s.cfg.Picker != nil { | |
| 371 | + | // Render the first committed publication immediately, before waiting | |
| 372 | + | // for the next one; the picker never shows a stale empty catalogue | |
| 373 | + | // while an established connection already has state. | |
| 374 | + | s.cfg.Picker.ApplySnapshot(service.Snapshot()) | |
| 375 | + | } | |
| 359 | 376 | s.transition(supervisorEvent{kind: supervisorReady}) | |
| 360 | 377 | ||
| 361 | - | lossErr, terminated, termErr := s.awaitLoss(ctx, input, service) | |
| 378 | + | lossErr, terminated, termErr := s.awaitLoss(ctx, input, service, sub) | |
| 362 | 379 | retire() | |
| 363 | 380 | if terminated { | |
| 364 | 381 | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | |
| @@ -511,14 +528,22 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet | |||
| 511 | 528 | // awaitLoss waits for an established connection to be lost, or for the run to | |
| 512 | 529 | // be terminated first. A loss is reported with the service's stable terminal | |
| 513 | 530 | // cause. | |
| 514 | - | func (s *Supervisor) awaitLoss(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService) (error, bool, error) { | |
| 515 | - | select { | |
| 516 | - | case <-service.Done(): | |
| 517 | - | return service.Err(), false, nil | |
| 518 | - | case <-ctx.Done(): | |
| 519 | - | return nil, true, ctx.Err() | |
| 520 | - | case err := <-input.EOF(): | |
| 521 | - | return nil, true, terminalReadCause(err) | |
| 531 | + | func (s *Supervisor) awaitLoss(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) (error, bool, error) { | |
| 532 | + | var changed <-chan struct{} | |
| 533 | + | if s.cfg.Picker != nil && !supervisorNil(sub) { | |
| 534 | + | changed = sub.Changed() | |
| 535 | + | } | |
| 536 | + | for { | |
| 537 | + | select { | |
| 538 | + | case <-service.Done(): | |
| 539 | + | return service.Err(), false, nil | |
| 540 | + | case <-changed: | |
| 541 | + | s.cfg.Picker.ApplySnapshot(service.Snapshot()) | |
| 542 | + | case <-ctx.Done(): | |
| 543 | + | return nil, true, ctx.Err() | |
| 544 | + | case err := <-input.EOF(): | |
| 545 | + | return nil, true, terminalReadCause(err) | |
| 546 | + | } | |
| 522 | 547 | } | |
| 523 | 548 | } | |
| 524 | 549 | ||
| @@ -671,17 +696,21 @@ func (s *Supervisor) transition(event supervisorEvent) { | |||
| 671 | 696 | // terminalInputLifetime owns the single read of the controlling terminal. It is | |
| 672 | 697 | // started once and never replaced: a bare io.Reader cannot be interrupted, so | |
| 673 | 698 | // the goroutine may outlive Run when stdin never reaches EOF, exactly like the | |
| 674 | - | // attach client's input pump. It never closes caller-owned input, and P5.1a | |
| 675 | - | // discards read bytes because picker interaction is a later slice. | |
| 699 | + | // attach client's input pump. It never closes caller-owned input. When a picker | |
| 700 | + | // consumer is supplied (Plan 001 P5.2b) each read is handed to it from this one | |
| 701 | + | // reader, so the picker never starts a second reader; without a consumer the | |
| 702 | + | // bytes are discarded exactly as P5.1a does. | |
| 676 | 703 | type terminalInputLifetime struct { | |
| 677 | - | eof chan error | |
| 678 | - | once sync.Once | |
| 704 | + | eof chan error | |
| 705 | + | once sync.Once | |
| 706 | + | consumer pickerInputConsumer | |
| 679 | 707 | } | |
| 680 | 708 | ||
| 681 | 709 | // startTerminalInputLifetime starts the single terminal read. A nil reader is | |
| 682 | - | // treated as an immediate orderly EOF. | |
| 683 | - | func startTerminalInputLifetime(in io.Reader) *terminalInputLifetime { | |
| 684 | - | lifetime := &terminalInputLifetime{eof: make(chan error, 1)} | |
| 710 | + | // treated as an immediate orderly EOF. A nil consumer discards read bytes; a | |
| 711 | + | // non-nil consumer owns them for the picker presentation. | |
| 712 | + | func startTerminalInputLifetime(in io.Reader, consumer pickerInputConsumer) *terminalInputLifetime { | |
| 713 | + | lifetime := &terminalInputLifetime{eof: make(chan error, 1), consumer: consumer} | |
| 685 | 714 | if supervisorNil(in) { | |
| 686 | 715 | lifetime.finish(io.EOF) | |
| 687 | 716 | return lifetime | |
| @@ -700,6 +729,11 @@ func startTerminalInputLifetime(in io.Reader) *terminalInputLifetime { | |||
| 700 | 729 | // mistaken for EOF. | |
| 701 | 730 | continue | |
| 702 | 731 | } | |
| 732 | + | if lifetime.consumer != nil { | |
| 733 | + | // The consumer may retain a partial escape or UTF-8 prefix, so it | |
| 734 | + | // gets its own copy rather than the reused read buffer. | |
| 735 | + | lifetime.consumer.ConsumeTerminalRead(append([]byte(nil), buffer[:n]...)) | |
| 736 | + | } | |
| 703 | 737 | } | |
| 704 | 738 | }() | |
| 705 | 739 | return lifetime | |
| @@ -216,6 +216,12 @@ type SupervisorConfig struct { | |||||
| 216 | // randomness seam so tests can pin the retry cadence exactly. Optional; the | 216 | // randomness seam so tests can pin the retry cadence exactly. Optional; the | ||
| 217 | // default is a process-local random source. | 217 | // default is a process-local random source. | ||
| 218 | Jitter func() float64 | 218 | Jitter func() float64 | ||
| 219 | + | // Picker is the client-owned picker (Plan 001 P5.2b). When set, the | |||
| 220 | + | // supervisor folds every broker publication into it and hands it every | |||
| 221 | + | // terminal read from the same single input lifetime used for EOF detection, | |||
| 222 | + | // so the picker never starts a second reader. When nil the supervisor drops | |||
| 223 | + | // terminal input exactly as P5.1a does. Optional. | |||
| 224 | + | Picker pickerHost | |||
| 219 | } | 225 | } | ||
| 220 | 226 | ||||
| 221 | // Supervisor owns one autonomous client process: raw mode, one terminal input | 227 | // Supervisor owns one autonomous client process: raw mode, one terminal input | ||
| @@ -243,6 +249,11 @@ func NewSupervisor(cfg SupervisorConfig) (*Supervisor, error) { | |||||
| 243 | if cfg.Render == nil { | 249 | if cfg.Render == nil { | ||
| 244 | cfg.Render = func(State) {} | 250 | cfg.Render = func(State) {} | ||
| 245 | } | 251 | } | ||
| 252 | + | if supervisorNil(cfg.Picker) { | |||
| 253 | + | // Normalize a typed-nil picker so Run's nil check and the input | |||
| 254 | + | // lifetime's consumer check are both safe. | |||
| 255 | + | cfg.Picker = nil | |||
| 256 | + | } | |||
| 246 | if cfg.Jitter == nil { | 257 | if cfg.Jitter == nil { | ||
| 247 | cfg.Jitter = rand.Float64 | 258 | cfg.Jitter = rand.Float64 | ||
| 248 | } | 259 | } | ||
| @@ -279,7 +290,7 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 279 | } | 290 | } | ||
| 280 | }() | 291 | }() | ||
| 281 | 292 | ||||
| 282 | - | input := startTerminalInputLifetime(s.cfg.Terminal.In()) | 293 | + | input := startTerminalInputLifetime(s.cfg.Terminal.In(), s.cfg.Picker) |
| 283 | 294 | ||||
| 284 | // The active connection is owned across loop iterations. retire is | 295 | // The active connection is owned across loop iterations. retire is | ||
| 285 | // idempotent so every exit path closes exactly the connection it adopted. | 296 | // idempotent so every exit path closes exactly the connection it adopted. | ||
| @@ -356,9 +367,15 @@ func (s *Supervisor) Run(ctx context.Context) (retErr error) { | |||||
| 356 | } | 367 | } | ||
| 357 | 368 | ||||
| 358 | service, sub = result.service, result.sub | 369 | service, sub = result.service, result.sub | ||
| 370 | + | if s.cfg.Picker != nil { | |||
| 371 | + | // Render the first committed publication immediately, before waiting | |||
| 372 | + | // for the next one; the picker never shows a stale empty catalogue | |||
| 373 | + | // while an established connection already has state. | |||
| 374 | + | s.cfg.Picker.ApplySnapshot(service.Snapshot()) | |||
| 375 | + | } | |||
| 359 | s.transition(supervisorEvent{kind: supervisorReady}) | 376 | s.transition(supervisorEvent{kind: supervisorReady}) | ||
| 360 | 377 | ||||
| 361 | - | lossErr, terminated, termErr := s.awaitLoss(ctx, input, service) | 378 | + | lossErr, terminated, termErr := s.awaitLoss(ctx, input, service, sub) |
| 362 | retire() | 379 | retire() | ||
| 363 | if terminated { | 380 | if terminated { | ||
| 364 | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | 381 | s.transition(supervisorEvent{kind: supervisorTerminal, err: termErr}) | ||
| @@ -511,14 +528,22 @@ func (s *Supervisor) awaitAttempt(ctx context.Context, input *terminalInputLifet | |||||
| 511 | // awaitLoss waits for an established connection to be lost, or for the run to | 528 | // awaitLoss waits for an established connection to be lost, or for the run to | ||
| 512 | // be terminated first. A loss is reported with the service's stable terminal | 529 | // be terminated first. A loss is reported with the service's stable terminal | ||
| 513 | // cause. | 530 | // cause. | ||
| 514 | - | func (s *Supervisor) awaitLoss(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService) (error, bool, error) { | 531 | + | func (s *Supervisor) awaitLoss(ctx context.Context, input *terminalInputLifetime, service ports.BrokerService, sub ports.BrokerSubscription) (error, bool, error) { |
| 515 | - | select { | 532 | + | var changed <-chan struct{} |
| 516 | - | case <-service.Done(): | 533 | + | if s.cfg.Picker != nil && !supervisorNil(sub) { |
| 517 | - | return service.Err(), false, nil | 534 | + | changed = sub.Changed() |
| 518 | - | case <-ctx.Done(): | 535 | + | } |
| 519 | - | return nil, true, ctx.Err() | 536 | + | for { |
| 520 | - | case err := <-input.EOF(): | 537 | + | select { |
| 521 | - | return nil, true, terminalReadCause(err) | 538 | + | case <-service.Done(): |
| 539 | + | return service.Err(), false, nil | |||
| 540 | + | case <-changed: | |||
| 541 | + | s.cfg.Picker.ApplySnapshot(service.Snapshot()) | |||
| 542 | + | case <-ctx.Done(): | |||
| 543 | + | return nil, true, ctx.Err() | |||
| 544 | + | case err := <-input.EOF(): | |||
| 545 | + | return nil, true, terminalReadCause(err) | |||
| 546 | + | } | |||
| 522 | } | 547 | } | ||
| 523 | } | 548 | } | ||
| 524 | 549 | ||||
| @@ -671,17 +696,21 @@ func (s *Supervisor) transition(event supervisorEvent) { | |||||
| 671 | // terminalInputLifetime owns the single read of the controlling terminal. It is | 696 | // terminalInputLifetime owns the single read of the controlling terminal. It is | ||
| 672 | // started once and never replaced: a bare io.Reader cannot be interrupted, so | 697 | // started once and never replaced: a bare io.Reader cannot be interrupted, so | ||
| 673 | // the goroutine may outlive Run when stdin never reaches EOF, exactly like the | 698 | // the goroutine may outlive Run when stdin never reaches EOF, exactly like the | ||
| 674 | - | // attach client's input pump. It never closes caller-owned input, and P5.1a | 699 | + | // attach client's input pump. It never closes caller-owned input. When a picker |
| 675 | - | // discards read bytes because picker interaction is a later slice. | 700 | + | // consumer is supplied (Plan 001 P5.2b) each read is handed to it from this one |
| 701 | + | // reader, so the picker never starts a second reader; without a consumer the | |||
| 702 | + | // bytes are discarded exactly as P5.1a does. | |||
| 676 | type terminalInputLifetime struct { | 703 | type terminalInputLifetime struct { | ||
| 677 | - | eof chan error | 704 | + | eof chan error |
| 678 | - | once sync.Once | 705 | + | once sync.Once |
| 706 | + | consumer pickerInputConsumer | |||
| 679 | } | 707 | } | ||
| 680 | 708 | ||||
| 681 | // startTerminalInputLifetime starts the single terminal read. A nil reader is | 709 | // startTerminalInputLifetime starts the single terminal read. A nil reader is | ||
| 682 | - | // treated as an immediate orderly EOF. | 710 | + | // treated as an immediate orderly EOF. A nil consumer discards read bytes; a |
| 683 | - | func startTerminalInputLifetime(in io.Reader) *terminalInputLifetime { | 711 | + | // non-nil consumer owns them for the picker presentation. |
| 684 | - | lifetime := &terminalInputLifetime{eof: make(chan error, 1)} | 712 | + | func startTerminalInputLifetime(in io.Reader, consumer pickerInputConsumer) *terminalInputLifetime { |
| 713 | + | lifetime := &terminalInputLifetime{eof: make(chan error, 1), consumer: consumer} | |||
| 685 | if supervisorNil(in) { | 714 | if supervisorNil(in) { | ||
| 686 | lifetime.finish(io.EOF) | 715 | lifetime.finish(io.EOF) | ||
| 687 | return lifetime | 716 | return lifetime | ||
| @@ -700,6 +729,11 @@ func startTerminalInputLifetime(in io.Reader) *terminalInputLifetime { | |||||
| 700 | // mistaken for EOF. | 729 | // mistaken for EOF. | ||
| 701 | continue | 730 | continue | ||
| 702 | } | 731 | } | ||
| 732 | + | if lifetime.consumer != nil { | |||
| 733 | + | // The consumer may retain a partial escape or UTF-8 prefix, so it | |||
| 734 | + | // gets its own copy rather than the reused read buffer. | |||
| 735 | + | lifetime.consumer.ConsumeTerminalRead(append([]byte(nil), buffer[:n]...)) | |||
| 736 | + | } | |||
| 703 | } | 737 | } | ||
| 704 | }() | 738 | }() | ||
| 705 | return lifetime | 739 | return lifetime | ||
M internal/usecase/client/supervisor_test.go +73 -12
| @@ -239,26 +239,32 @@ func (c *supervisorTestConnector) awaitStart(t *testing.T) int { | |||
| 239 | 239 | } | |
| 240 | 240 | } | |
| 241 | 241 | ||
| 242 | - | // supervisorTestHub is one broker publication source. subscribe hands out the | |
| 243 | - | // current change channel, and publish retires and replaces it, so a stale | |
| 244 | - | // subscription can never observe a newer publication. | |
| 242 | + | // supervisorTestHub is one broker publication source. It conforms to | |
| 243 | + | // ports.BrokerSubscription: each subscriber owns its own capacity-one channel, | |
| 244 | + | // a publication offers a non-blocking wake to every live subscriber and never | |
| 245 | + | // replaces or closes a channel, and a subscriber re-reads the newest snapshot | |
| 246 | + | // on wake. A slow or parked subscriber therefore can never block the | |
| 247 | + | // publisher or busy-spin. | |
| 245 | 248 | type supervisorTestHub struct { | |
| 246 | 249 | mu sync.Mutex | |
| 247 | 250 | snapshot ports.BrokerSnapshot | |
| 248 | - | changed chan struct{} | |
| 251 | + | subs map[chan struct{}]struct{} | |
| 249 | 252 | } | |
| 250 | 253 | ||
| 251 | 254 | func newSupervisorTestHub() *supervisorTestHub { | |
| 252 | - | return &supervisorTestHub{changed: make(chan struct{})} | |
| 255 | + | return &supervisorTestHub{subs: make(map[chan struct{}]struct{})} | |
| 253 | 256 | } | |
| 254 | 257 | ||
| 255 | 258 | func (h *supervisorTestHub) publish(snapshot ports.BrokerSnapshot) { | |
| 256 | 259 | h.mu.Lock() | |
| 260 | + | defer h.mu.Unlock() | |
| 257 | 261 | h.snapshot = snapshot | |
| 258 | - | changed := h.changed | |
| 259 | - | h.changed = make(chan struct{}) | |
| 260 | - | h.mu.Unlock() | |
| 261 | - | close(changed) | |
| 262 | + | for changed := range h.subs { | |
| 263 | + | select { | |
| 264 | + | case changed <- struct{}{}: | |
| 265 | + | default: | |
| 266 | + | } | |
| 267 | + | } | |
| 262 | 268 | } | |
| 263 | 269 | ||
| 264 | 270 | func (h *supervisorTestHub) current() ports.BrokerSnapshot { | |
| @@ -268,14 +274,22 @@ func (h *supervisorTestHub) current() ports.BrokerSnapshot { | |||
| 268 | 274 | } | |
| 269 | 275 | ||
| 270 | 276 | func (h *supervisorTestHub) subscribe(closeFn func()) ports.BrokerSubscription { | |
| 277 | + | changed := make(chan struct{}, 1) | |
| 271 | 278 | h.mu.Lock() | |
| 272 | - | changed := h.changed | |
| 279 | + | h.subs[changed] = struct{}{} | |
| 273 | 280 | h.mu.Unlock() | |
| 274 | - | return supervisorTestSubscription{changed: changed, closeFn: closeFn} | |
| 281 | + | return supervisorTestSubscription{changed: changed, closeFn: func() { | |
| 282 | + | h.mu.Lock() | |
| 283 | + | delete(h.subs, changed) | |
| 284 | + | h.mu.Unlock() | |
| 285 | + | if closeFn != nil { | |
| 286 | + | closeFn() | |
| 287 | + | } | |
| 288 | + | }} | |
| 275 | 289 | } | |
| 276 | 290 | ||
| 277 | 291 | type supervisorTestSubscription struct { | |
| 278 | - | changed <-chan struct{} | |
| 292 | + | changed chan struct{} | |
| 279 | 293 | closeFn func() | |
| 280 | 294 | } | |
| 281 | 295 | ||
| @@ -409,6 +423,12 @@ func (s *supervisorTestService) publish(epoch ports.BrokerEpoch, revision ports. | |||
| 409 | 423 | s.hub.publish(ports.BrokerSnapshot{Epoch: epoch, Revision: revision}) | |
| 410 | 424 | } | |
| 411 | 425 | ||
| 426 | + | // publishSnapshot publishes one complete broker snapshot, as a real service | |
| 427 | + | // does, so a test can deliver a second-and-later catalogue publication. | |
| 428 | + | func (s *supervisorTestService) publishSnapshot(snapshot ports.BrokerSnapshot) { | |
| 429 | + | s.hub.publish(snapshot) | |
| 430 | + | } | |
| 431 | + | ||
| 412 | 432 | func (s *supervisorTestService) closedCh() <-chan struct{} { return s.closed } | |
| 413 | 433 | ||
| 414 | 434 | func mustSupervisor(t *testing.T, cfg SupervisorConfig) *Supervisor { | |
| @@ -1282,3 +1302,44 @@ func TestSupervisorNilSubscriptionIsTypedUnavailable(t *testing.T) { | |||
| 1282 | 1302 | }) | |
| 1283 | 1303 | } | |
| 1284 | 1304 | } | |
| 1305 | + | ||
| 1306 | + | // TestSupervisorTestHubModelsSubscriptionContract pins the fake against | |
| 1307 | + | // ports.BrokerSubscription: each subscriber owns a distinct capacity-one | |
| 1308 | + | // channel that is never replaced or closed on publish, and a publication is a | |
| 1309 | + | // non-blocking offer so a parked subscriber can never busy-spin the publisher. | |
| 1310 | + | func TestSupervisorTestHubModelsSubscriptionContract(t *testing.T) { | |
| 1311 | + | hub := newSupervisorTestHub() | |
| 1312 | + | first := hub.subscribe(nil) | |
| 1313 | + | second := hub.subscribe(nil) | |
| 1314 | + | require.NotEqual(t, first.Changed(), second.Changed(), "each subscriber owns its own channel") | |
| 1315 | + | ||
| 1316 | + | hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 1}) | |
| 1317 | + | for name, sub := range map[string]ports.BrokerSubscription{"first": first, "second": second} { | |
| 1318 | + | select { | |
| 1319 | + | case <-sub.Changed(): | |
| 1320 | + | default: | |
| 1321 | + | t.Fatalf("subscriber %s must receive the publication", name) | |
| 1322 | + | } | |
| 1323 | + | } | |
| 1324 | + | ||
| 1325 | + | // A second publication while a wake is still buffered is a non-blocking | |
| 1326 | + | // drop, and the channel identity is never replaced. | |
| 1327 | + | channel := first.Changed() | |
| 1328 | + | hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 2}) | |
| 1329 | + | hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 3}) | |
| 1330 | + | require.Equal(t, channel, first.Changed()) | |
| 1331 | + | select { | |
| 1332 | + | case <-first.Changed(): | |
| 1333 | + | default: | |
| 1334 | + | t.Fatal("a publication after the drained wake must offer a new wake") | |
| 1335 | + | } | |
| 1336 | + | ||
| 1337 | + | first.Close() | |
| 1338 | + | hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 4}) | |
| 1339 | + | select { | |
| 1340 | + | case <-first.Changed(): | |
| 1341 | + | t.Fatal("a closed subscription must not receive further wakes") | |
| 1342 | + | default: | |
| 1343 | + | } | |
| 1344 | + | require.Equal(t, ports.BrokerRevision(4), hub.current().Revision) | |
| 1345 | + | } | |
| @@ -239,26 +239,32 @@ func (c *supervisorTestConnector) awaitStart(t *testing.T) int { | |||||
| 239 | } | 239 | } | ||
| 240 | } | 240 | } | ||
| 241 | 241 | ||||
| 242 | - | // supervisorTestHub is one broker publication source. subscribe hands out the | 242 | + | // supervisorTestHub is one broker publication source. It conforms to |
| 243 | - | // current change channel, and publish retires and replaces it, so a stale | 243 | + | // ports.BrokerSubscription: each subscriber owns its own capacity-one channel, |
| 244 | - | // subscription can never observe a newer publication. | 244 | + | // a publication offers a non-blocking wake to every live subscriber and never |
| 245 | + | // replaces or closes a channel, and a subscriber re-reads the newest snapshot | |||
| 246 | + | // on wake. A slow or parked subscriber therefore can never block the | |||
| 247 | + | // publisher or busy-spin. | |||
| 245 | type supervisorTestHub struct { | 248 | type supervisorTestHub struct { | ||
| 246 | mu sync.Mutex | 249 | mu sync.Mutex | ||
| 247 | snapshot ports.BrokerSnapshot | 250 | snapshot ports.BrokerSnapshot | ||
| 248 | - | changed chan struct{} | 251 | + | subs map[chan struct{}]struct{} |
| 249 | } | 252 | } | ||
| 250 | 253 | ||||
| 251 | func newSupervisorTestHub() *supervisorTestHub { | 254 | func newSupervisorTestHub() *supervisorTestHub { | ||
| 252 | - | return &supervisorTestHub{changed: make(chan struct{})} | 255 | + | return &supervisorTestHub{subs: make(map[chan struct{}]struct{})} |
| 253 | } | 256 | } | ||
| 254 | 257 | ||||
| 255 | func (h *supervisorTestHub) publish(snapshot ports.BrokerSnapshot) { | 258 | func (h *supervisorTestHub) publish(snapshot ports.BrokerSnapshot) { | ||
| 256 | h.mu.Lock() | 259 | h.mu.Lock() | ||
| 260 | + | defer h.mu.Unlock() | |||
| 257 | h.snapshot = snapshot | 261 | h.snapshot = snapshot | ||
| 258 | - | changed := h.changed | 262 | + | for changed := range h.subs { |
| 259 | - | h.changed = make(chan struct{}) | 263 | + | select { |
| 260 | - | h.mu.Unlock() | 264 | + | case changed <- struct{}{}: |
| 261 | - | close(changed) | 265 | + | default: |
| 266 | + | } | |||
| 267 | + | } | |||
| 262 | } | 268 | } | ||
| 263 | 269 | ||||
| 264 | func (h *supervisorTestHub) current() ports.BrokerSnapshot { | 270 | func (h *supervisorTestHub) current() ports.BrokerSnapshot { | ||
| @@ -268,14 +274,22 @@ func (h *supervisorTestHub) current() ports.BrokerSnapshot { | |||||
| 268 | } | 274 | } | ||
| 269 | 275 | ||||
| 270 | func (h *supervisorTestHub) subscribe(closeFn func()) ports.BrokerSubscription { | 276 | func (h *supervisorTestHub) subscribe(closeFn func()) ports.BrokerSubscription { | ||
| 277 | + | changed := make(chan struct{}, 1) | |||
| 271 | h.mu.Lock() | 278 | h.mu.Lock() | ||
| 272 | - | changed := h.changed | 279 | + | h.subs[changed] = struct{}{} |
| 273 | h.mu.Unlock() | 280 | h.mu.Unlock() | ||
| 274 | - | return supervisorTestSubscription{changed: changed, closeFn: closeFn} | 281 | + | return supervisorTestSubscription{changed: changed, closeFn: func() { |
| 282 | + | h.mu.Lock() | |||
| 283 | + | delete(h.subs, changed) | |||
| 284 | + | h.mu.Unlock() | |||
| 285 | + | if closeFn != nil { | |||
| 286 | + | closeFn() | |||
| 287 | + | } | |||
| 288 | + | }} | |||
| 275 | } | 289 | } | ||
| 276 | 290 | ||||
| 277 | type supervisorTestSubscription struct { | 291 | type supervisorTestSubscription struct { | ||
| 278 | - | changed <-chan struct{} | 292 | + | changed chan struct{} |
| 279 | closeFn func() | 293 | closeFn func() | ||
| 280 | } | 294 | } | ||
| 281 | 295 | ||||
| @@ -409,6 +423,12 @@ func (s *supervisorTestService) publish(epoch ports.BrokerEpoch, revision ports. | |||||
| 409 | s.hub.publish(ports.BrokerSnapshot{Epoch: epoch, Revision: revision}) | 423 | s.hub.publish(ports.BrokerSnapshot{Epoch: epoch, Revision: revision}) | ||
| 410 | } | 424 | } | ||
| 411 | 425 | ||||
| 426 | + | // publishSnapshot publishes one complete broker snapshot, as a real service | |||
| 427 | + | // does, so a test can deliver a second-and-later catalogue publication. | |||
| 428 | + | func (s *supervisorTestService) publishSnapshot(snapshot ports.BrokerSnapshot) { | |||
| 429 | + | s.hub.publish(snapshot) | |||
| 430 | + | } | |||
| 431 | + | ||||
| 412 | func (s *supervisorTestService) closedCh() <-chan struct{} { return s.closed } | 432 | func (s *supervisorTestService) closedCh() <-chan struct{} { return s.closed } | ||
| 413 | 433 | ||||
| 414 | func mustSupervisor(t *testing.T, cfg SupervisorConfig) *Supervisor { | 434 | func mustSupervisor(t *testing.T, cfg SupervisorConfig) *Supervisor { | ||
| @@ -1282,3 +1302,44 @@ func TestSupervisorNilSubscriptionIsTypedUnavailable(t *testing.T) { | |||||
| 1282 | }) | 1302 | }) | ||
| 1283 | } | 1303 | } | ||
| 1284 | } | 1304 | } | ||
| 1305 | + | ||||
| 1306 | + | // TestSupervisorTestHubModelsSubscriptionContract pins the fake against | |||
| 1307 | + | // ports.BrokerSubscription: each subscriber owns a distinct capacity-one | |||
| 1308 | + | // channel that is never replaced or closed on publish, and a publication is a | |||
| 1309 | + | // non-blocking offer so a parked subscriber can never busy-spin the publisher. | |||
| 1310 | + | func TestSupervisorTestHubModelsSubscriptionContract(t *testing.T) { | |||
| 1311 | + | hub := newSupervisorTestHub() | |||
| 1312 | + | first := hub.subscribe(nil) | |||
| 1313 | + | second := hub.subscribe(nil) | |||
| 1314 | + | require.NotEqual(t, first.Changed(), second.Changed(), "each subscriber owns its own channel") | |||
| 1315 | + | ||||
| 1316 | + | hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 1}) | |||
| 1317 | + | for name, sub := range map[string]ports.BrokerSubscription{"first": first, "second": second} { | |||
| 1318 | + | select { | |||
| 1319 | + | case <-sub.Changed(): | |||
| 1320 | + | default: | |||
| 1321 | + | t.Fatalf("subscriber %s must receive the publication", name) | |||
| 1322 | + | } | |||
| 1323 | + | } | |||
| 1324 | + | ||||
| 1325 | + | // A second publication while a wake is still buffered is a non-blocking | |||
| 1326 | + | // drop, and the channel identity is never replaced. | |||
| 1327 | + | channel := first.Changed() | |||
| 1328 | + | hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 2}) | |||
| 1329 | + | hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 3}) | |||
| 1330 | + | require.Equal(t, channel, first.Changed()) | |||
| 1331 | + | select { | |||
| 1332 | + | case <-first.Changed(): | |||
| 1333 | + | default: | |||
| 1334 | + | t.Fatal("a publication after the drained wake must offer a new wake") | |||
| 1335 | + | } | |||
| 1336 | + | ||||
| 1337 | + | first.Close() | |||
| 1338 | + | hub.publish(ports.BrokerSnapshot{Epoch: 1, Revision: 4}) | |||
| 1339 | + | select { | |||
| 1340 | + | case <-first.Changed(): | |||
| 1341 | + | t.Fatal("a closed subscription must not receive further wakes") | |||
| 1342 | + | default: | |||
| 1343 | + | } | |||
| 1344 | + | require.Equal(t, ports.BrokerRevision(4), hub.current().Revision) | |||
| 1345 | + | } | |||