1package daemon
2
3import (
4 "context"
5 "encoding/base64"
6 "errors"
7 "fmt"
8 "sort"
9 "strings"
10 "time"
11
12 "github.com/bnema/vev/internal/domain"
13 "github.com/bnema/vev/internal/ports"
14 "github.com/bnema/vev/internal/usecase/picker"
15)
16
17type remotePickerCatalogEntry struct {
18 entry ports.RemoteCatalogCacheEntry
19 status remoteHostStatus
20}
21
22// remotePickerInstance is an immutable ownership capability for one published
23// picker lifecycle. The model identity prevents delayed registration from
24// attaching ownership to a replacement within the same client.
25type remotePickerInstance struct {
26 ac *attachedClient
27 generation uint64
28 model *picker.Model
29}
30
31// remotePickerCatalogSnapshot copies the cache-derived state needed for a
32// picker before any row sorting begins. No remote-state lock survives this
33// snapshot.
34const remotePickerAttachTTL = 30 * time.Second
35
36func (d *Daemon) remotePickerCatalogSnapshot() []remotePickerCatalogEntry {
37 if d == nil {
38 return nil
39 }
40 now := d.clock.Now()
41 d.remoteCatalog.mu.Lock()
42 for host, entry := range d.remoteCatalog.cache {
43 if d.remoteCatalog.status[host] == remoteHostFresh && remoteCatalogExpired(entry.FetchedAt, now) {
44 d.remoteCatalog.status[host] = remoteHostStale
45 }
46 }
47 entries := make([]remotePickerCatalogEntry, 0, len(d.remoteCatalog.cache)+len(d.remoteCatalog.status))
48 seen := make(map[string]struct{}, len(d.remoteCatalog.cache))
49 for host, entry := range d.remoteCatalog.cache {
50 entries = append(entries, remotePickerCatalogEntry{
51 entry: cloneRemoteCatalogEntry(entry),
52 status: d.remoteCatalog.status[host],
53 })
54 seen[host] = struct{}{}
55 }
56 for host, status := range d.remoteCatalog.status {
57 if _, exists := seen[host]; exists || (status != remoteHostUnreachable && status != remoteHostVersionMismatch && status != remoteHostMalformed) {
58 continue
59 }
60 entries = append(entries, remotePickerCatalogEntry{
61 entry: ports.RemoteCatalogCacheEntry{Host: host},
62 status: status,
63 })
64 }
65 d.remoteCatalog.mu.Unlock()
66 return entries
67}
68
69func (d *Daemon) remotePickerHostRanks() map[string]int {
70 ranks := make(map[string]int)
71 if d == nil || d.remoteHostStore == nil {
72 return ranks
73 }
74 pinned, learned, err := d.remoteHostStore.Hosts()
75 if err != nil {
76 d.log.Warn("listing remote hosts for picker failed", "err", err)
77 return ranks
78 }
79 for _, host := range append(pinned, learned...) {
80 if _, exists := ranks[host]; !exists {
81 ranks[host] = len(ranks)
82 }
83 }
84 return ranks
85}
86
87func remoteCatalogExpired(fetchedAt, now time.Time) bool {
88 return fetchedAt.IsZero() || (!now.Before(fetchedAt) && now.Sub(fetchedAt) >= remotePickerAttachTTL)
89}
90
91func remotePickerAvailability(status remoteHostStatus) picker.RemoteAvailability {
92 switch status {
93 case remoteHostFresh:
94 return picker.RemoteFresh
95 case remoteHostStale, remoteHostUnreachable, remoteHostMalformed:
96 return picker.RemoteStale
97 case remoteHostVersionMismatch:
98 return picker.RemoteVersionMismatch
99 default:
100 return picker.RemoteCached
101 }
102}
103
104func remotePickerStatusDetail(status remoteHostStatus, fetchedAt time.Time) string {
105 switch status {
106 case remoteHostStale:
107 if !fetchedAt.IsZero() {
108 return "catalog stale since " + fetchedAt.Format(time.RFC3339)
109 }
110 return "catalog stale"
111 case remoteHostUnreachable:
112 if !fetchedAt.IsZero() {
113 return "stale since " + fetchedAt.Format(time.RFC3339)
114 }
115 return "unreachable"
116 case remoteHostVersionMismatch:
117 return "version mismatch"
118 case remoteHostMalformed:
119 return "catalog malformed"
120 default:
121 return ""
122 }
123}
124
125func remoteSessionOrigin(host string) string {
126 if i := strings.LastIndexByte(host, '@'); i >= 0 && i+1 < len(host) {
127 return host[i+1:]
128 }
129 return host
130}
131
132func remoteSessionStateStopped(state string) bool {
133 return state == "down" || state == "stopped"
134}
135
136func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
137 key.LifecycleID = session.LifecycleID
138 key.DisplayOrigin = remoteSessionOrigin(key.Host)
139 availability := remotePickerAvailability(status)
140 stopped := remoteSessionStateStopped(session.State)
141 broken := session.State == "broken"
142 tabs := ports.CatalogTabs(session)
143 viewTabs := make([]picker.TabEntry, 0, len(tabs))
144 active := 0
145 for i, tab := range tabs {
146 if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
147 active = i
148 }
149 name := tab.Name
150 if name == "" {
151 name = fmt.Sprintf("%d", int(tab.Index)+1)
152 }
153 viewTabs = append(viewTabs, picker.TabEntry{
154 TabID: domain.TabStableID(tab.ID),
155 Name: name,
156 RawName: tab.Name,
157 Detail: tab.Detail,
158 Attention: tab.Attention,
159 })
160 }
161 legacy := session.LifecycleID == (domain.SessionLifecycleID{})
162 var remoteTarget *domain.RemoteSessionTarget
163 if !legacy {
164 target := domain.RemoteSessionTarget{
165 Endpoint: key.Host,
166 DisplayOrigin: key.DisplayOrigin,
167 LifecycleID: session.LifecycleID,
168 SessionName: session.Name,
169 Stopped: stopped,
170 }
171 if len(tabs) != 0 {
172 first := tabs[0]
173 if stopped {
174 if first.ID != "" {
175 target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID))
176 } else {
177 target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(len(tabs)))
178 }
179 } else {
180 target.LiveTabID = domain.TabStableID(tabs[active].ID)
181 }
182 }
183 // Keep the structured session identity on rows even when one tab ID is
184 // malformed or a broken session cannot be activated. Cursor navigation
185 // must remain possible, while sendRemoteAttachTargetForAttachment will
186 // fail closed on the invalid target.
187 remoteTarget = &target
188 }
189 reason := ""
190 if !legacy && status != remoteHostFresh {
191 reason = remoteReasonForStatus(status)
192 }
193 targetValid := remoteTarget != nil && remoteTarget.Validate() == nil
194 if broken {
195 reason = "session_broken"
196 } else if !legacy && !targetValid {
197 reason = "identity_changed"
198 }
199 ready := status == remoteHostFresh && targetValid && !broken
200 if legacy {
201 // Count-only peers cannot prove lifecycle/tab identity. Preserve the
202 // historical row for compatibility, but make it read-only until a fresh
203 // catalog response authorizes a name attach.
204 ready = status == remoteHostFresh && !broken && !stopped
205 if status == remoteHostVersionMismatch {
206 reason = remoteReasonForStatus(status)
207 }
208 }
209 detail := remotePickerStatusDetail(status, fetchedAt)
210 if detail == "" {
211 switch session.State {
212 case "up":
213 detail = "up"
214 case "down", "stopped":
215 detail = "stopped"
216 case "broken":
217 detail = "broken"
218 }
219 }
220 if broken {
221 detail = "session broken"
222 }
223 return picker.SessionView{
224 ID: key.ID(),
225 Name: key.Display(),
226 RemoteKey: &key,
227 RemoteTarget: remoteTarget,
228 RemoteHost: key.Host,
229 RemoteAvailability: availability,
230 RemoteDetail: detail,
231 RemoteReason: reason,
232 Tabs: viewTabs,
233 Active: active,
234 Stopped: stopped,
235 ConnectOnly: legacy,
236 RemoteAttachReady: ready,
237 CannotAcceptMoves: true,
238 }
239}
240
241func remoteReasonForStatus(status remoteHostStatus) string {
242 switch status {
243 case remoteHostRefreshing:
244 return "refreshing"
245 case remoteHostStale:
246 return "catalog_stale"
247 case remoteHostUnreachable:
248 return "host_unreachable"
249 case remoteHostVersionMismatch:
250 return "version_mismatch"
251 case remoteHostMalformed:
252 return "malformed"
253 case remoteHostCached:
254 return "refreshing"
255 default:
256 return ""
257 }
258}
259
260func remotePickerHostView(host string, status remoteHostStatus) picker.SessionView {
261 return picker.SessionView{
262 ID: domain.SessionID("remote-host:" + base64.RawURLEncoding.EncodeToString([]byte(host))),
263 Name: host,
264 RemoteHost: host,
265 RemoteReason: remoteReasonForStatus(status),
266 RemoteAvailability: remotePickerAvailability(status),
267 RemoteDetail: remotePickerStatusDetail(status, time.Time{}),
268 ConnectOnly: true,
269 CannotAcceptMoves: true,
270 }
271}
272
273func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {
274 for i := range entries {
275 sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {
276 l, r := entries[i].entry.Sessions[left], entries[i].entry.Sessions[right]
277 if l.LastUsedSeq != r.LastUsedSeq {
278 return l.LastUsedSeq > r.LastUsedSeq
279 }
280 return l.Name < r.Name
281 })
282 }
283 sort.Slice(entries, func(i, j int) bool {
284 left, right := entries[i].entry.Host, entries[j].entry.Host
285 leftRank, leftKnown := ranks[left]
286 rightRank, rightKnown := ranks[right]
287 if leftKnown != rightKnown {
288 return leftKnown
289 }
290 if leftKnown && leftRank != rightRank {
291 return leftRank < rightRank
292 }
293 return left < right
294 })
295}
296
297func (d *Daemon) remotePickerOpened(instance remotePickerInstance) {
298 if d.registerRemotePicker(instance) {
299 d.startRemotePickerRefresh(instance)
300 }
301}
302
303// registerRemotePicker installs one exact picker generation as a refresh owner.
304// Keeping this step separate gives refresh startup a second atomic validation
305// point: a close can win between registration and startup without being undone
306// by a delayed opener.
307func (d *Daemon) registerRemotePicker(instance remotePickerInstance) bool {
308 if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
309 return false
310 }
311 rt := instance.ac.overlays
312 // Picker ownership always nests pickerMu -> remoteCatalog.mu. No path may
313 // acquire pickerMu while retaining the catalog lock.
314 rt.pickerMu.Lock()
315 defer rt.pickerMu.Unlock()
316 if rt.pickerGeneration != instance.generation || rt.picker != instance.model {
317 return false
318 }
319 d.remoteCatalog.mu.Lock()
320 d.remoteCatalog.pickers[instance.ac] = struct{}{}
321 d.remoteCatalog.mu.Unlock()
322 return true
323}
324
325func (d *Daemon) remotePickerClosed(instance remotePickerInstance) {
326 if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
327 return
328 }
329 rt := instance.ac.overlays
330 rt.pickerMu.Lock()
331 current := rt.pickerGeneration == instance.generation && rt.picker == nil
332 if !current {
333 rt.pickerMu.Unlock()
334 return
335 }
336 d.remoteCatalog.mu.Lock()
337 delete(d.remoteCatalog.pickers, instance.ac)
338 if len(d.remoteCatalog.pickers) != 0 {
339 d.remoteCatalog.mu.Unlock()
340 rt.pickerMu.Unlock()
341 return
342 }
343 d.remoteCatalog.refresh++
344 cancel := d.remoteCatalog.cancel
345 d.remoteCatalog.cancel = nil
346 d.remoteCatalog.mu.Unlock()
347 rt.pickerMu.Unlock()
348 if cancel != nil {
349 cancel()
350 }
351}
352
353func (d *Daemon) cancelRemotePickerRefresh() {
354 if d == nil {
355 return
356 }
357 d.remoteCatalog.mu.Lock()
358 d.remoteCatalog.refresh++
359 cancel := d.remoteCatalog.cancel
360 d.remoteCatalog.cancel = nil
361 d.remoteCatalog.mu.Unlock()
362 if cancel != nil {
363 cancel()
364 }
365}
366
367func (d *Daemon) remotePickerHosts() ([]string, error) {
368 if d == nil || d.remoteHostStore == nil {
369 return nil, nil
370 }
371 pinned, learned, err := d.remoteHostStore.Hosts()
372 if err != nil {
373 return nil, err
374 }
375 hosts := make([]string, 0, len(pinned)+len(learned))
376 seen := make(map[string]struct{}, cap(hosts))
377 add := func(candidates []string) {
378 for _, host := range candidates {
379 if _, exists := seen[host]; exists {
380 continue
381 }
382 seen[host] = struct{}{}
383 hosts = append(hosts, host)
384 }
385 }
386 add(pinned)
387 add(learned)
388 return hosts, nil
389}
390
391// startRemotePickerRefresh replaces the current refresh generation and launches
392// one bounded catalog call per known host. Installing the generation is gated
393// by the exact picker generation that registered ownership. Host-store and
394// remote I/O happen after every architecture lock has been released.
395func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64 {
396 if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
397 return 0
398 }
399 root := context.Background()
400 if d.serveCtx != nil {
401 root = d.serveCtx
402 }
403 ctx, cancel := context.WithCancel(root)
404
405 rt := instance.ac.overlays
406 rt.pickerMu.Lock()
407 d.remoteCatalog.mu.Lock()
408 _, owner := d.remoteCatalog.pickers[instance.ac]
409 current := rt.pickerGeneration == instance.generation && rt.picker == instance.model
410 if !current || !owner || len(d.remoteCatalog.pickers) == 0 {
411 d.remoteCatalog.mu.Unlock()
412 rt.pickerMu.Unlock()
413 cancel()
414 return 0
415 }
416 previous := d.remoteCatalog.cancel
417 d.remoteCatalog.refresh++
418 generation := d.remoteCatalog.refresh
419 d.remoteCatalog.cancel = cancel
420 d.remoteCatalog.mu.Unlock()
421 rt.pickerMu.Unlock()
422 if previous != nil {
423 previous()
424 }
425 if d.remoteCatalogClient == nil {
426 return generation
427 }
428
429 hosts, err := d.remotePickerHosts()
430 if err != nil {
431 d.log.Warn("listing remote hosts for refresh failed", "err", err)
432 return generation
433 }
434 known := make(map[string]struct{}, len(hosts))
435 for _, host := range hosts {
436 known[host] = struct{}{}
437 }
438
439 d.remoteCatalog.mu.Lock()
440 if d.remoteCatalog.refresh != generation {
441 d.remoteCatalog.mu.Unlock()
442 cancel()
443 return generation
444 }
445 cacheChanged := false
446 for host := range d.remoteCatalog.cache {
447 if _, exists := known[host]; exists {
448 continue
449 }
450 delete(d.remoteCatalog.cache, host)
451 delete(d.remoteCatalog.status, host)
452 delete(d.remoteCatalog.failure, host)
453 cacheChanged = true
454 }
455 for _, host := range hosts {
456 d.remoteCatalog.status[host] = remoteHostRefreshing
457 }
458 d.remoteCatalog.mu.Unlock()
459 if cacheChanged {
460 d.persistRemoteCatalogCache()
461 }
462 d.refreshRemoteOpenPickers()
463
464 for _, host := range hosts {
465 d.sessWg.Go(func() {
466 catalog, listErr := d.remoteCatalogClient.List(ctx, host)
467 d.applyRemoteRefreshResult(generation, host, catalog, listErr)
468 })
469 }
470 return generation
471}
472
473func containsRemotePickerHost(hosts []string, target string) bool {
474 for _, host := range hosts {
475 if host == target {
476 return true
477 }
478 }
479 return false
480}
481
482// applyRemoteRefreshResult publishes one host completion only if both its
483// refresh generation and current host-registry membership remain authoritative.
484// Its return reports whether durable cache state changed.
485func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalog ports.RemoteCatalog, listErr error) bool {
486 if d == nil {
487 return false
488 }
489 d.remoteCatalog.mu.Lock()
490 current := d.remoteCatalog.refresh == generation
491 d.remoteCatalog.mu.Unlock()
492 if !current {
493 return false
494 }
495
496 hosts, hostsErr := d.remotePickerHosts()
497 if hostsErr != nil {
498 d.log.Warn("revalidating remote host refresh failed", "host", host, "err", hostsErr)
499 return false
500 }
501 stillKnown := containsRemotePickerHost(hosts, host)
502 now := d.clock.Now()
503 if listErr == nil {
504 if err := ports.ValidateRemoteCatalog(catalog); err != nil {
505 listErr = err
506 }
507 }
508
509 d.remoteCatalog.mu.Lock()
510 if d.remoteCatalog.refresh != generation {
511 d.remoteCatalog.mu.Unlock()
512 return false
513 }
514 if !stillKnown {
515 _, cacheChanged := d.remoteCatalog.cache[host]
516 delete(d.remoteCatalog.cache, host)
517 delete(d.remoteCatalog.status, host)
518 delete(d.remoteCatalog.failure, host)
519 d.remoteCatalog.mu.Unlock()
520 if cacheChanged {
521 d.persistRemoteCatalogCache()
522 d.refreshRemoteOpenPickers()
523 }
524 return cacheChanged
525 }
526 if listErr != nil {
527 status := remoteHostMalformed
528 var mismatch *ports.RemoteCatalogVersionMismatchError
529 if errors.As(listErr, &mismatch) {
530 status = remoteHostVersionMismatch
531 } else if !errors.Is(listErr, ports.ErrInvalidRemoteCatalog) &&
532 !errors.Is(listErr, ports.ErrRemoteCatalogTooLarge) &&
533 !errors.Is(listErr, ports.ErrRemoteCatalogUnknownState) &&
534 !errors.Is(listErr, ports.ErrRemoteCatalogInvalidReason) {
535 status = remoteHostUnreachable
536 }
537 d.remoteCatalog.status[host] = status
538 d.remoteCatalog.failure[host] = now
539 d.remoteCatalog.mu.Unlock()
540 d.refreshRemoteOpenPickers()
541 return false
542 }
543 d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(ports.RemoteCatalogCacheEntry{
544 Host: host,
545 FetchedAt: now,
546 Sessions: catalog.Sessions,
547 })
548 d.remoteCatalog.status[host] = remoteHostFresh
549 delete(d.remoteCatalog.failure, host)
550 d.remoteCatalog.mu.Unlock()
551
552 d.persistRemoteCatalogCache()
553 d.refreshRemoteOpenPickers()
554 return true
555}
556
557func (d *Daemon) refreshRemoteOpenPickers() {
558 if d == nil {
559 return
560 }
561 d.remoteCatalog.mu.Lock()
562 clients := make([]*attachedClient, 0, len(d.remoteCatalog.pickers))
563 for ac := range d.remoteCatalog.pickers {
564 clients = append(clients, ac)
565 }
566 d.remoteCatalog.mu.Unlock()
567
568 for _, ac := range clients {
569 if ac == nil || ac.overlays == nil {
570 continue
571 }
572 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
573 if entry := ac.currentAttachmentSession(); entry != nil {
574 d.invalidateRender(entry, ac, true, "remote_picker.go")
575 }
576 }
577}