1package daemon
2
3import (
4 "context"
5 "encoding/base64"
6 "errors"
7 "fmt"
8 "math"
9 "sort"
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 remoteHostCached, remoteHostRefreshing:
107 return "checking remote…"
108 case remoteHostStale:
109 if !fetchedAt.IsZero() {
110 return "catalog stale since " + fetchedAt.Format(time.RFC3339)
111 }
112 return "catalog stale"
113 case remoteHostUnreachable:
114 if !fetchedAt.IsZero() {
115 return "stale since " + fetchedAt.Format(time.RFC3339)
116 }
117 return "unreachable"
118 case remoteHostVersionMismatch:
119 return "version mismatch"
120 case remoteHostMalformed:
121 return "catalog malformed"
122 default:
123 return ""
124 }
125}
126
127func remoteSessionStateStopped(state string) bool {
128 return state == "down" || state == "stopped"
129}
130
131func remotePickerView(key domain.RemoteSessionKey, session ports.RemoteCatalogSession, status remoteHostStatus, fetchedAt time.Time) picker.SessionView {
132 key.LifecycleID = session.LifecycleID
133 key.DisplayOrigin = domain.RemoteDisplayOrigin(key.Host)
134 availability := remotePickerAvailability(status)
135 stopped := remoteSessionStateStopped(session.State)
136 broken := session.State == "broken"
137 tabs := ports.CatalogTabs(session)
138 viewTabs := make([]picker.TabEntry, 0, len(tabs))
139 active := 0
140 for i, tab := range tabs {
141 if session.ActiveTabID != "" && tab.ID == session.ActiveTabID {
142 active = i
143 }
144 name := tab.Name
145 if name == "" {
146 name = fmt.Sprintf("%d", int(tab.Index)+1)
147 }
148 viewTabs = append(viewTabs, picker.TabEntry{
149 TabID: domain.TabStableID(tab.ID),
150 Name: name,
151 RawName: tab.Name,
152 Detail: tab.Detail,
153 Attention: tab.Attention,
154 })
155 }
156 legacy := session.LifecycleID == (domain.SessionLifecycleID{})
157 target := domain.RemoteSessionTarget{
158 Endpoint: key.Host,
159 DisplayOrigin: key.DisplayOrigin,
160 LifecycleID: session.LifecycleID,
161 SessionName: session.Name,
162 Stopped: stopped,
163 }
164 if len(tabs) != 0 {
165 first := tabs[0]
166 if stopped {
167 if first.ID != "" {
168 target.StoppedTab = domain.NewStableTabSelector(domain.TabStableID(first.ID))
169 } else {
170 tabCount := min(len(tabs), math.MaxUint16)
171 target.StoppedTab = domain.NewOrdinalTabSelector(0, first.Name, uint16(tabCount))
172 }
173 } else {
174 target.LiveTabID = domain.TabStableID(tabs[active].ID)
175 }
176 }
177 // Keep the structured session identity on rows even when one tab ID is
178 // malformed or a broken session cannot be activated. Cursor navigation
179 // must remain possible, while sendRemoteAttachTargetForAttachment will
180 // fail closed on the invalid target.
181 var remoteTarget *domain.RemoteSessionTarget
182 if !legacy {
183 remoteTarget = &target
184 }
185 reason := remoteReasonForStatus(status)
186 targetValid := remoteTarget != nil && remoteTarget.Validate() == nil
187 activation := picker.RemoteUnavailable
188 if broken {
189 reason = "session_broken"
190 } else if !legacy && !targetValid {
191 reason = "identity_changed"
192 } else if status == remoteHostFresh {
193 if legacy {
194 if !stopped {
195 activation = picker.RemoteAttach
196 }
197 } else if stopped {
198 activation = picker.RemoteRestart
199 } else {
200 activation = picker.RemoteAttach
201 }
202 }
203
204 detail := remotePickerStatusDetail(status, fetchedAt)
205 if detail == "" {
206 switch {
207 case broken:
208 detail = "session broken"
209 case !legacy && !targetValid:
210 detail = "identity changed"
211 case legacy && stopped:
212 detail = "stopped — exact identity required"
213 case activation == picker.RemoteRestart:
214 detail = "stopped — Enter to restart"
215 case session.State == "up":
216 detail = "up"
217 case stopped:
218 detail = "stopped"
219 default:
220 detail = session.State
221 }
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 RemoteActivation: activation,
233 Tabs: viewTabs,
234 Active: active,
235 Stopped: stopped,
236 ConnectOnly: legacy,
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 RemoteActivation: picker.RemoteUnavailable,
269 ConnectOnly: true,
270 CannotAcceptMoves: true,
271 }
272}
273
274func sortRemotePickerCatalog(entries []remotePickerCatalogEntry, ranks map[string]int) {
275 for i := range entries {
276 sort.Slice(entries[i].entry.Sessions, func(left, right int) bool {
277 l, r := entries[i].entry.Sessions[left], entries[i].entry.Sessions[right]
278 if l.LastUsedSeq != r.LastUsedSeq {
279 return l.LastUsedSeq > r.LastUsedSeq
280 }
281 return l.Name < r.Name
282 })
283 }
284 sort.Slice(entries, func(i, j int) bool {
285 left, right := entries[i].entry.Host, entries[j].entry.Host
286 leftRank, leftKnown := ranks[left]
287 rightRank, rightKnown := ranks[right]
288 if leftKnown != rightKnown {
289 return leftKnown
290 }
291 if leftKnown && leftRank != rightRank {
292 return leftRank < rightRank
293 }
294 return left < right
295 })
296}
297
298func (d *Daemon) remotePickerOpened(instance remotePickerInstance) {
299 if d.registerRemotePicker(instance) {
300 d.startRemotePickerRefresh(instance)
301 }
302}
303
304// registerRemotePicker installs one exact picker generation as a refresh owner.
305// Keeping this step separate gives refresh startup a second atomic validation
306// point: a close can win between registration and startup without being undone
307// by a delayed opener.
308func (d *Daemon) registerRemotePicker(instance remotePickerInstance) bool {
309 if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
310 return false
311 }
312 rt := instance.ac.overlays
313 // Picker ownership always nests pickerMu -> remoteCatalog.mu. No path may
314 // acquire pickerMu while retaining the catalog lock.
315 rt.pickerMu.Lock()
316 defer rt.pickerMu.Unlock()
317 if rt.pickerGeneration != instance.generation || rt.picker != instance.model {
318 return false
319 }
320 d.remoteCatalog.mu.Lock()
321 d.remoteCatalog.pickers[instance.ac] = struct{}{}
322 d.remoteCatalog.mu.Unlock()
323 return true
324}
325
326func (d *Daemon) remotePickerClosed(instance remotePickerInstance) {
327 if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
328 return
329 }
330 rt := instance.ac.overlays
331 rt.pickerMu.Lock()
332 current := rt.pickerGeneration == instance.generation && rt.picker == nil
333 if !current {
334 rt.pickerMu.Unlock()
335 return
336 }
337 d.remoteCatalog.mu.Lock()
338 delete(d.remoteCatalog.pickers, instance.ac)
339 if len(d.remoteCatalog.pickers) != 0 {
340 d.remoteCatalog.mu.Unlock()
341 rt.pickerMu.Unlock()
342 return
343 }
344 d.remoteCatalog.refresh++
345 cancel := d.remoteCatalog.cancel
346 d.remoteCatalog.cancel = nil
347 d.remoteCatalog.mu.Unlock()
348 rt.pickerMu.Unlock()
349 if cancel != nil {
350 cancel()
351 }
352}
353
354func (d *Daemon) cancelRemotePickerRefresh() {
355 if d == nil {
356 return
357 }
358 d.remoteCatalog.mu.Lock()
359 d.remoteCatalog.refresh++
360 cancel := d.remoteCatalog.cancel
361 d.remoteCatalog.cancel = nil
362 d.remoteCatalog.mu.Unlock()
363 if cancel != nil {
364 cancel()
365 }
366}
367
368func (d *Daemon) remotePickerHosts() ([]string, error) {
369 if d == nil || d.remoteHostStore == nil {
370 return nil, nil
371 }
372 pinned, learned, err := d.remoteHostStore.Hosts()
373 if err != nil {
374 return nil, err
375 }
376 hosts := make([]string, 0, len(pinned)+len(learned))
377 seen := make(map[string]struct{}, cap(hosts))
378 add := func(candidates []string) {
379 for _, host := range candidates {
380 if _, exists := seen[host]; exists {
381 continue
382 }
383 seen[host] = struct{}{}
384 hosts = append(hosts, host)
385 }
386 }
387 add(pinned)
388 add(learned)
389 return hosts, nil
390}
391
392// startRemotePickerRefresh replaces the current refresh generation and launches
393// one bounded catalog call per known host. Installing the generation is gated
394// by the exact picker generation that registered ownership. Host-store and
395// remote I/O happen after every architecture lock has been released.
396func (d *Daemon) startRemotePickerRefresh(instance remotePickerInstance) uint64 {
397 if d == nil || instance.ac == nil || instance.ac.overlays == nil || instance.model == nil {
398 return 0
399 }
400 root := context.Background()
401 if d.serveCtx != nil {
402 root = d.serveCtx
403 }
404 ctx, cancel := context.WithCancel(root)
405
406 rt := instance.ac.overlays
407 rt.pickerMu.Lock()
408 d.remoteCatalog.mu.Lock()
409 _, owner := d.remoteCatalog.pickers[instance.ac]
410 current := rt.pickerGeneration == instance.generation && rt.picker == instance.model
411 if !current || !owner || len(d.remoteCatalog.pickers) == 0 {
412 d.remoteCatalog.mu.Unlock()
413 rt.pickerMu.Unlock()
414 cancel()
415 return 0
416 }
417 previous := d.remoteCatalog.cancel
418 d.remoteCatalog.refresh++
419 generation := d.remoteCatalog.refresh
420 d.remoteCatalog.cancel = cancel
421 d.remoteCatalog.mu.Unlock()
422 rt.pickerMu.Unlock()
423 if previous != nil {
424 previous()
425 }
426 if d.remoteCatalogClient == nil {
427 return generation
428 }
429
430 hosts, err := d.remotePickerHosts()
431 if err != nil {
432 d.log.Warn("listing remote hosts for refresh failed", "err", err)
433 return generation
434 }
435 known := make(map[string]struct{}, len(hosts))
436 for _, host := range hosts {
437 known[host] = struct{}{}
438 }
439
440 d.remoteCatalog.mu.Lock()
441 if d.remoteCatalog.refresh != generation {
442 d.remoteCatalog.mu.Unlock()
443 cancel()
444 return generation
445 }
446 cacheChanged := false
447 for host := range d.remoteCatalog.cache {
448 if _, exists := known[host]; exists {
449 continue
450 }
451 delete(d.remoteCatalog.cache, host)
452 delete(d.remoteCatalog.status, host)
453 delete(d.remoteCatalog.failure, host)
454 cacheChanged = true
455 }
456 for _, host := range hosts {
457 d.remoteCatalog.status[host] = remoteHostRefreshing
458 }
459 d.remoteCatalog.mu.Unlock()
460 if cacheChanged {
461 d.persistRemoteCatalogCache()
462 }
463 d.refreshRemoteOpenPickers()
464
465 for _, host := range hosts {
466 d.sessWg.Go(func() {
467 catalog, listErr := d.remoteCatalogClient.List(ctx, host)
468 d.applyRemoteRefreshResult(generation, host, catalog, listErr)
469 })
470 }
471 return generation
472}
473
474func containsRemotePickerHost(hosts []string, target string) bool {
475 for _, host := range hosts {
476 if host == target {
477 return true
478 }
479 }
480 return false
481}
482
483// applyRemoteRefreshResult publishes one host completion only if both its
484// refresh generation and current host-registry membership remain authoritative.
485// Its return reports whether durable cache state changed.
486func (d *Daemon) applyRemoteRefreshResult(generation uint64, host string, catalog ports.RemoteCatalog, listErr error) bool {
487 if d == nil {
488 return false
489 }
490 d.remoteCatalog.mu.Lock()
491 current := d.remoteCatalog.refresh == generation
492 d.remoteCatalog.mu.Unlock()
493 if !current {
494 return false
495 }
496
497 hosts, hostsErr := d.remotePickerHosts()
498 if hostsErr != nil {
499 d.log.Warn("revalidating remote host refresh failed", "host", host, "err", hostsErr)
500 return false
501 }
502 stillKnown := containsRemotePickerHost(hosts, host)
503 now := d.clock.Now()
504 if listErr == nil {
505 if err := ports.ValidateRemoteCatalog(catalog); err != nil {
506 listErr = err
507 }
508 }
509
510 d.remoteCatalog.mu.Lock()
511 if d.remoteCatalog.refresh != generation {
512 d.remoteCatalog.mu.Unlock()
513 return false
514 }
515 if !stillKnown {
516 _, cacheChanged := d.remoteCatalog.cache[host]
517 delete(d.remoteCatalog.cache, host)
518 delete(d.remoteCatalog.status, host)
519 delete(d.remoteCatalog.failure, host)
520 d.remoteCatalog.mu.Unlock()
521 if cacheChanged {
522 d.persistRemoteCatalogCache()
523 d.refreshRemoteOpenPickers()
524 }
525 return cacheChanged
526 }
527 if listErr != nil {
528 status := remoteHostMalformed
529 var mismatch *ports.RemoteCatalogVersionMismatchError
530 if errors.As(listErr, &mismatch) {
531 status = remoteHostVersionMismatch
532 } else if !errors.Is(listErr, ports.ErrInvalidRemoteCatalog) &&
533 !errors.Is(listErr, ports.ErrRemoteCatalogTooLarge) &&
534 !errors.Is(listErr, ports.ErrRemoteCatalogUnknownState) &&
535 !errors.Is(listErr, ports.ErrRemoteCatalogInvalidReason) {
536 status = remoteHostUnreachable
537 }
538 d.remoteCatalog.status[host] = status
539 d.remoteCatalog.failure[host] = now
540 d.remoteCatalog.mu.Unlock()
541 d.refreshRemoteOpenPickers()
542 return false
543 }
544 d.remoteCatalog.cache[host] = cloneRemoteCatalogEntry(ports.RemoteCatalogCacheEntry{
545 Host: host,
546 FetchedAt: now,
547 Sessions: catalog.Sessions,
548 })
549 d.remoteCatalog.status[host] = remoteHostFresh
550 delete(d.remoteCatalog.failure, host)
551 d.remoteCatalog.mu.Unlock()
552
553 d.persistRemoteCatalogCache()
554 d.refreshRemoteOpenPickers()
555 return true
556}
557
558func (d *Daemon) refreshRemoteOpenPickers() {
559 if d == nil {
560 return
561 }
562 d.remoteCatalog.mu.Lock()
563 clients := make([]*attachedClient, 0, len(d.remoteCatalog.pickers))
564 for ac := range d.remoteCatalog.pickers {
565 clients = append(clients, ac)
566 }
567 d.remoteCatalog.mu.Unlock()
568
569 for _, ac := range clients {
570 if ac == nil || ac.overlays == nil {
571 continue
572 }
573 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
574 if entry := ac.currentAttachmentSession(); entry != nil {
575 d.invalidateRender(entry, ac, true, "remote_picker.go")
576 }
577 }
578}