1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5 "context"
6 "errors"
7 "sort"
8 "time"
9
10 renderer "github.com/bnema/vev-vt"
11 "github.com/bnema/vev/internal/domain"
12 "github.com/bnema/vev/internal/ports"
13 "github.com/bnema/vev/internal/usecase/layout"
14 "github.com/bnema/vev/internal/usecase/picker"
15 "github.com/bnema/vev/internal/usecase/ui"
16)
17
18var pickerModal = ui.Modal{WidthPct: 80, HeightPct: 80, MinWidth: 24, MinHeight: 8, Title: " Sessions ", Anchor: domain.AnchorCenter, Margins: ui.Margins{}}
19
20// pickerSortMode orders the picker's live sessions. It lives for the daemon's
21// lifetime only and is never persisted.
22type pickerSortMode uint32
23
24const (
25 pickerSortRecent pickerSortMode = iota
26 pickerSortGrouped
27
28 remotePickerPreviewDebounce = 80 * time.Millisecond
29)
30
31func pickerTitle(mode pickerSortMode) string {
32 if mode == pickerSortGrouped {
33 return " Sessions · grouped "
34 }
35 return " Sessions · recent "
36}
37
38// togglePickerSort flips the daemon's picker sort mode between the two known
39// modes with a CompareAndSwap loop, so a concurrent double-press can't lose
40// an update the way a bare Load-then-Store XOR can.
41func (d *Daemon) togglePickerSort() {
42 for {
43 cur := pickerSortMode(d.pickerSort.Load())
44 next := pickerSortRecent
45 if cur == pickerSortRecent {
46 next = pickerSortGrouped
47 }
48 if d.pickerSort.CompareAndSwap(uint32(cur), uint32(next)) {
49 return
50 }
51 }
52}
53
54// enterPicker preserves the existing navigation entry point. Navigation always
55// publishes its model, including an empty one; only move entry can fail for a
56// missing destination.
57func (d *Daemon) enterPicker(sess *session, ac *attachedClient) {
58 model := d.newPickerModel(sess, ac, pickerNavigate, moveSourceLocator{}, picker.SourceFilter{})
59 d.publishPicker(sess, ac, model, pickerNavigate, moveSourceLocator{})
60}
61
62func (d *Daemon) publishPicker(sess *session, ac *attachedClient, model *picker.Model, intent pickerIntent, source moveSourceLocator) {
63 rt := ac.overlays
64 rt.pickerMu.Lock()
65 previous, previousGeneration := rt.pickerPreviewSession, rt.pickerPreviewGeneration
66 rt.pickerPreviewGeneration++
67 if rt.pickerRemotePreviewCancel != nil {
68 rt.pickerRemotePreviewCancel()
69 rt.pickerRemotePreviewCancel = nil
70 }
71 rt.pickerPreviewSession = nil
72 rt.pickerRemotePreview = picker.Preview{}
73 rt.pickerPreview = nil
74 rt.picker = model
75 rt.pickerGeneration++
76 instance := remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: model}
77 rt.pickerTitle = pickerTitle(pickerSortMode(d.pickerSort.Load()))
78 rt.pickerIntent = intent
79 rt.pickerSource = source
80 rt.pickerPending = nil
81 rt.pickerESC.stop()
82 rt.pickerMu.Unlock()
83 d.teardownPreviewSubscription(ac, previous, previousGeneration)
84 d.registerPreviewForSelection(ac)
85 d.invalidateRender(sess, ac, true, "picker.go")
86 if rt.beforeRemotePickerRegistration != nil {
87 rt.beforeRemotePickerRegistration()
88 }
89 d.remotePickerOpened(instance)
90}
91
92// pickerViews captures one canonical lifecycle/tab snapshot. It intentionally
93// knows nothing about picker intent; picker.New owns all destination policy.
94func (d *Daemon) pickerViews(cur *session, ac *attachedClient) ([]picker.SessionView, picker.SourceFilter) {
95 d.mu.Lock()
96 sessions := make([]*session, 0, len(d.sessions))
97 for _, entry := range d.sessions {
98 if entry == nil {
99 continue
100 }
101 sessions = append(sessions, entry)
102 }
103 stopped := make([]stoppedSession, 0, len(d.stopped))
104 for _, s := range d.stopped {
105 if !s.purging {
106 stopped = append(stopped, s)
107 }
108 }
109 d.mu.Unlock()
110
111 for _, entry := range sessions {
112 if entry != nil {
113 d.refreshSessionFocusedTitles(entry)
114 }
115 }
116 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
117
118 type liveSnapshot struct {
119 entry *session
120 view sessionView
121 }
122 // One snapshot per live session: sorting and view building read the same
123 // capture, so comparators cannot observe a concurrent touchMRU or
124 // renameSession mid-sort. Remote-catalog locks are released before sorting
125 // and picker-row construction.
126 live := make([]liveSnapshot, 0, len(sessions))
127 var current picker.SourceFilter
128 for _, entry := range sessions {
129 if entry == cur && ac != nil {
130 entry.repairAttachmentView(ac)
131 }
132 snap := entry.snapshotView(opts)
133 if entry == cur {
134 if ac != nil {
135 view := ac.viewSnapshot()
136 if view.tabID != "" {
137 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID}
138 }
139 } else if len(snap.tabs) > 0 {
140 current = picker.SourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.defaultTab].id}
141 }
142 }
143 live = append(live, liveSnapshot{entry: entry, view: snap})
144 }
145 sort.Slice(live, func(i, j int) bool {
146 if live[i].view.mruAt != live[j].view.mruAt {
147 return live[i].view.mruAt > live[j].view.mruAt
148 }
149 if live[i].view.name != live[j].view.name {
150 return live[i].view.name < live[j].view.name
151 }
152 return live[i].view.id < live[j].view.id
153 })
154 sort.Slice(stopped, func(i, j int) bool {
155 if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {
156 return stopped[i].lastUsedSeq > stopped[j].lastUsedSeq
157 }
158 return stopped[i].name < stopped[j].name
159 })
160 if pickerSortMode(d.pickerSort.Load()) == pickerSortGrouped {
161 // Stable partition: named sessions first, ephemeral after, each keeping
162 // its MRU order from the sort above.
163 sort.SliceStable(live, func(i, j int) bool {
164 return !live[i].view.ephemeral && live[j].view.ephemeral
165 })
166 }
167
168 catalog := d.remotePickerCatalogSnapshot()
169 sortRemotePickerCatalog(catalog, d.remotePickerHostRanks())
170
171 catalogRows := 0
172 for _, host := range catalog {
173 catalogRows += len(host.entry.Sessions)
174 if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) {
175 catalogRows++
176 }
177 }
178 views := make([]picker.SessionView, 0, len(live)+len(stopped)+catalogRows)
179 for _, item := range live {
180 views = append(views, item.view.pickerView())
181 }
182 seenRemoteLifecycles := make(map[domain.SessionLifecycleID]struct{}, catalogRows)
183 for _, host := range catalog {
184 for _, session := range host.entry.Sessions {
185 if session.LifecycleID != (domain.SessionLifecycleID{}) {
186 if _, duplicate := seenRemoteLifecycles[session.LifecycleID]; duplicate {
187 continue
188 }
189 seenRemoteLifecycles[session.LifecycleID] = struct{}{}
190 }
191 key := domain.RemoteSessionKey{Host: host.entry.Host, Name: session.Name}
192 if key.Validate() != nil {
193 continue
194 }
195 views = append(views, remotePickerView(key, session, host.status, host.entry.FetchedAt))
196 }
197 if len(host.entry.Sessions) == 0 && (host.status == remoteHostUnreachable || host.status == remoteHostVersionMismatch || host.status == remoteHostMalformed) {
198 views = append(views, remotePickerHostView(host.entry.Host, host.status))
199 }
200 }
201 for _, s := range stopped {
202 createdAt := s.createdAt
203 views = append(views, picker.SessionView{
204 ID: domain.SessionID("stopped:" + s.name),
205 Incarnation: s.incarnation,
206 Name: s.name,
207 TargetName: s.name,
208 Tabs: []picker.TabEntry{{}},
209 Stopped: true,
210 ExpectedCreatedAt: &createdAt,
211 })
212 }
213 return views, current
214}
215
216func (d *Daemon) newPickerModel(cur *session, ac *attachedClient, intent pickerIntent, source moveSourceLocator, current picker.SourceFilter) *picker.Model {
217 views, attachedCurrent := d.pickerViews(cur, ac)
218 if intent == pickerNavigate && current == (picker.SourceFilter{}) {
219 current = attachedCurrent
220 }
221 return picker.New(views, picker.SelectionConfig{
222 Mode: pickerSelectionMode(intent),
223 Current: current,
224 Source: picker.SourceFilter{
225 Session: source.Session.ID, Incarnation: source.Session.Incarnation, TabID: source.TabID,
226 },
227 })
228}
229
230func attentionSuffix(label string) string {
231 return label + " " + string(ui.AttentionGlyph)
232}
233
234func (d *Daemon) notifyRemotePickerUnavailable(sess *session, target picker.Target) {
235 reason := target.UnavailableReason
236 if reason == "" {
237 reason = "identity_changed"
238 }
239 message := "Remote session unavailable"
240 if target.RemoteTarget != nil {
241 message += ": " + target.RemoteTarget.SessionName + "@" + target.RemoteTarget.DisplayOrigin
242 } else if target.RemoteHost != "" {
243 message += ": " + target.RemoteHost
244 }
245 message += " — " + remotePickerReasonText(reason)
246 d.notify(sess, domain.NoticeWarn, domain.NoticeSessionUnavailable, message, nil)
247}
248
249func remotePickerReasonText(reason string) string {
250 switch reason {
251 case "catalog_stale":
252 return "catalog stale"
253 case "host_unreachable":
254 return "host unreachable"
255 case "version_mismatch":
256 return "version mismatch"
257 case "session_stopped":
258 return "session stopped"
259 case "session_broken":
260 return "session broken"
261 case "malformed":
262 return "catalog malformed"
263 case "refreshing":
264 return "refreshing"
265 case "identity_changed":
266 return "session identity changed"
267 default:
268 return "unavailable"
269 }
270}
271
272func (d *Daemon) pickerListInputState(ac *attachedClient) listInputState {
273 rt := ac.overlays
274 var previewGeneration uint64
275 var instance remotePickerInstance
276 return listInputState{
277 pending: &rt.pickerPending,
278 esc: &rt.pickerESC,
279 moveUp: rt.picker.Up,
280 moveDown: rt.picker.Down,
281 lock: rt.pickerMu.Lock,
282 unlock: rt.pickerMu.Unlock,
283 active: func() bool { return rt.picker != nil },
284 closeLocked: func() {
285 instance = remotePickerInstance{ac: ac, generation: rt.pickerGeneration, model: rt.picker}
286 rt.picker = nil
287 rt.pickerIntent = pickerNavigate
288 rt.pickerSource = moveSourceLocator{}
289 previewGeneration = rt.pickerPreviewGeneration
290 },
291 afterClose: func() {
292 d.clearPreviewGeneration(ac, previewGeneration)
293 d.remotePickerClosed(instance)
294 if sess := ac.currentAttachmentSession(); sess != nil {
295 d.invalidateRender(sess, ac, true, "picker.go")
296 }
297 },
298 }
299}
300
301func (d *Daemon) handlePickerInput(ac *attachedClient, data []byte, effects ...*attachmentEffectTicket) {
302 sess := ac.currentAttachmentSession()
303 if sess == nil {
304 return
305 }
306 rt := ac.overlays
307 rt.pickerMu.Lock()
308 if rt.picker == nil {
309 rt.pickerPending = nil
310 rt.pickerESC.stop()
311 rt.pickerMu.Unlock()
312 return
313 }
314 result := handleListInputLocked(d.clock, data, d.pickerListInputState(ac), func(b byte) listInputResult {
315 switch b {
316 case 'x':
317 return listInputResult{action: b, stop: true}
318 case 's':
319 return listInputResult{action: b, stop: true}
320 case '\r', '\n':
321 return listInputResult{action: b, exit: true}
322 default:
323 return listInputResult{}
324 }
325 })
326 var target picker.Target
327 var cursor picker.Target
328 var ok bool
329 var cursorOK bool
330 var intent pickerIntent
331 var source moveSourceLocator
332 // prevIdx is the row the victim occupied; -1 means "no post-delete hint".
333 prevIdx := -1
334 if result.action == 'x' || result.action == '\r' || result.action == '\n' {
335 target, ok = rt.picker.Selected()
336 cursor, cursorOK = rt.picker.Cursor()
337 prevIdx = rt.picker.SelectedIndex()
338 }
339 intent, source = rt.pickerIntent, rt.pickerSource
340 rt.pickerMu.Unlock()
341
342 if result.action == 's' {
343 d.togglePickerSort()
344 d.refreshPickerOpts(ac, pickerRefreshOptions{preserveSelection: true, nearestRow: -1})
345 d.invalidateRender(sess, ac, true, "picker.go")
346 return
347 }
348 if (result.action == '\r' || result.action == '\n') && !ok && cursorOK && (cursor.RemoteTarget != nil || cursor.RemoteHost != "") {
349 d.notifyRemotePickerUnavailable(sess, cursor)
350 d.invalidateRender(sess, ac, true, "picker.go")
351 return
352 }
353 if result.action == 'x' {
354 var effect *attachmentEffectTicket
355 if len(effects) != 0 {
356 effect = effects[0]
357 }
358 if ok {
359 var err error
360 if effect != nil {
361 effect.bindActionEnd(d, "picker-delete")
362 err = d.killPickerTargetForAttachment(target, effect.connectionToken())
363 } else {
364 err = d.killPickerTarget(target)
365 }
366 if err != nil {
367 d.reportAttachmentError(sess, err)
368 }
369 }
370 if effect != nil && effect.ended.Load() {
371 fresh, admitted := ac.beginAttachmentEffect(effect.token)
372 if !admitted {
373 return
374 }
375 defer fresh.End()
376 }
377 d.refreshPickerOpts(ac, pickerRefreshOptions{nearestRow: prevIdx})
378 d.invalidateRender(sess, ac, true, "picker.go")
379 return
380 }
381 if result.changed {
382 d.registerPreviewForSelection(ac)
383 }
384 committing := (result.action == '\r' || result.action == '\n') && ok
385 backNavigationSent := false
386 if result.exit && !committing {
387 back := ac.startupOverlay == ports.StartupOverlaySessionPicker && ac.navigationCapabilities&ports.NavigationCapabilityBack != 0
388 if back {
389 var token attachmentConnectionToken
390 if len(effects) != 0 && effects[0] != nil {
391 token = effects[0].connectionToken()
392 } else {
393 var effect *attachmentEffectTicket
394 var admitted bool
395 token, effect, admitted = ac.beginCurrentAttachmentEffect(sess, ac.transport())
396 if !admitted {
397 d.invalidateRender(sess, ac, true, "picker.go")
398 return
399 }
400 defer effect.End()
401 }
402 if err := d.sendNavigationActionForAttachment(token, ports.NavigationBack); err != nil {
403 d.reportAttachmentError(sess, err)
404 d.invalidateRender(sess, ac, true, "picker.go")
405 return
406 }
407 backNavigationSent = true
408 }
409 d.closePicker(ac)
410 }
411 if committing {
412 if intent == pickerMovePane || intent == pickerMoveTab {
413 if err := d.movePickerSourceError(source); err != nil {
414 d.reportAttachmentError(sess, err)
415 d.invalidateRender(sess, ac, true, "picker.go")
416 return
417 }
418 if len(effects) != 0 && effects[0] != nil {
419 effects[0].End()
420 }
421 d.closePicker(ac)
422 err := d.commitMovePickerSelection(intent, source, target)
423 if err != nil {
424 d.reportAttachmentError(sess, movePickerUserError(err))
425 d.invalidateRender(sess, ac, true, "picker.go")
426 return
427 }
428 d.invalidateRender(sess, ac, true, "picker.go")
429 return
430 }
431 var err error
432 if len(effects) != 0 && effects[0] != nil {
433 err = d.switchToTargetForAttachment(effects[0].connectionToken(), target, sessionHandoffGuard{closePicker: true}, "picker-select")
434 } else {
435 d.closePicker(ac)
436 err = d.switchToTarget(sess, ac, target)
437 }
438 if errors.Is(err, errAttachmentTransition) {
439 return
440 }
441 if err != nil {
442 d.reportAttachmentError(sess, err)
443 return
444 }
445 return
446 }
447 if (result.exit || result.changed) && !backNavigationSent {
448 d.invalidateRender(sess, ac, true, "picker.go")
449 }
450}
451
452func (d *Daemon) registerPreviewForSelection(ac *attachedClient) {
453 if ac == nil || ac.overlays == nil {
454 return
455 }
456 ac.overlays.pickerMu.Lock()
457 var target picker.Target
458 var ok bool
459 intent := ac.overlays.pickerIntent
460 if ac.overlays.picker != nil {
461 target, ok = ac.overlays.picker.Selected()
462 }
463 previous, previousGeneration := ac.overlays.pickerPreviewSession, ac.overlays.pickerPreviewGeneration
464 ac.overlays.pickerPreviewGeneration++
465 generation := ac.overlays.pickerPreviewGeneration
466 if ac.overlays.pickerRemotePreviewCancel != nil {
467 ac.overlays.pickerRemotePreviewCancel()
468 ac.overlays.pickerRemotePreviewCancel = nil
469 }
470 ac.overlays.pickerPreviewSession = nil
471 ac.overlays.pickerRemotePreview = picker.Preview{}
472 ac.overlays.pickerPreview = nil
473 ac.overlays.pickerMu.Unlock()
474 d.teardownPreviewSubscription(ac, previous, previousGeneration)
475 if !ok {
476 return
477 }
478 if target.RemoteTarget != nil {
479 d.startRemotePickerPreview(ac, target, generation)
480 return
481 }
482 targetSess, next := d.previewTarget(target, intent)
483 if next == nil || targetSess == nil {
484 return
485 }
486 ac.overlays.pickerMu.Lock()
487 // Selection may have changed while the target was resolved.
488 selected, stillSelected := ac.overlays.picker.Selected()
489 valid := ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerIntent == intent && stillSelected && pickerTargetsEqual(selected, target)
490 if valid {
491 ac.overlays.pickerPreview = next
492 ac.overlays.pickerPreviewSession = targetSess
493 }
494 ac.overlays.pickerMu.Unlock()
495 if !valid {
496 return
497 }
498 // A same-session preview is already invalidated by its own coordinator.
499 // Cross-session previews subscribe to the target's producer wake instead
500 // of starting a direct paint/timer path.
501 if targetSess == ac.currentAttachmentSession() {
502 return
503 }
504 rc := d.attachCoordinator(targetSess, nil, nil, false)
505 rc.subscribePreviewFor(ac, generation, func(renderWake) {
506 if pickerPreviewCurrent(ac, targetSess, next, generation) {
507 if owner := ac.currentAttachmentSession(); owner != nil {
508 d.invalidateRender(owner, ac, false, "picker preview")
509 }
510 }
511 })
512 // subscribePreviewFor is deliberately outside pickerMu. Revalidate after it
513 // returns and remove only this generation if selection changed meanwhile.
514 d.revalidatePreviewSubscription(ac, rc, targetSess, next, generation)
515}
516
517func (d *Daemon) startRemotePickerPreview(ac *attachedClient, target picker.Target, generation uint64) {
518 if d == nil || ac == nil || target.RemoteTarget == nil || (d.remotePreviewClient == nil && !target.RemoteTarget.Stopped) {
519 return
520 }
521 width, height := remotePickerPreviewSize(ac.sizeSnapshot())
522 if width == 0 || height == 0 {
523 return
524 }
525 ctx := d.serveCtx
526 if ctx == nil {
527 ctx = context.Background()
528 }
529 previewCtx, cancel := context.WithCancel(ctx)
530 ac.overlays.pickerMu.Lock()
531 if ac.overlays.picker == nil || ac.overlays.pickerPreviewGeneration != generation {
532 ac.overlays.pickerMu.Unlock()
533 cancel()
534 return
535 }
536 ac.overlays.pickerRemotePreviewCancel = cancel
537 remoteTarget := *target.RemoteTarget
538 message := "loading remote preview…"
539 if remoteTarget.Stopped {
540 message = "stopped session — preview unavailable"
541 }
542 ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, message)
543 if remoteTarget.Stopped {
544 ac.overlays.pickerRemotePreviewCancel = nil
545 ac.overlays.pickerMu.Unlock()
546 cancel()
547 if sess := ac.currentAttachmentSession(); sess != nil {
548 d.invalidateRender(sess, ac, false, "remote picker preview")
549 }
550 return
551 }
552 ac.overlays.pickerMu.Unlock()
553 go func() {
554 defer cancel()
555 previewClock := d.clock
556 if previewClock == nil {
557 previewClock = systemClock{}
558 }
559 debounce := previewClock.NewTimer(remotePickerPreviewDebounce)
560 defer debounce.Stop()
561 select {
562 case <-debounce.C():
563 case <-previewCtx.Done():
564 return
565 }
566 preview, err := d.fetchRemotePreview(previewCtx, remoteTarget, width, height)
567 if err != nil {
568 ac.overlays.pickerMu.Lock()
569 matching := false
570 if ac.overlays.pickerPreviewGeneration == generation && ac.overlays.picker != nil {
571 selected, stillSelected := ac.overlays.picker.Selected()
572 matching = stillSelected && pickerTargetsEqual(selected, target)
573 if matching {
574 ac.overlays.pickerRemotePreview = staticRemotePickerPreview(width, height, "remote preview unavailable")
575 }
576 }
577 ac.overlays.pickerMu.Unlock()
578 if matching {
579 if sess := ac.currentAttachmentSession(); sess != nil {
580 d.invalidateRender(sess, ac, false, "remote picker preview")
581 }
582 }
583 return
584 }
585 if refreshed, refreshErr := d.awaitRemotePreviewRefresh(previewCtx, remoteTarget, width, height); refreshErr == nil {
586 preview = refreshed
587 } else if previewCtx.Err() != nil {
588 return
589 }
590 rows := preview.FrameRows()
591 candidate := picker.Preview{Rows: rows, Width: int(preview.Width), Height: int(preview.Height)}
592 ac.overlays.pickerMu.Lock()
593 if ac.overlays.pickerPreviewGeneration != generation || ac.overlays.picker == nil {
594 ac.overlays.pickerMu.Unlock()
595 return
596 }
597 selected, stillSelected := ac.overlays.picker.Selected()
598 if !stillSelected || !pickerTargetsEqual(selected, target) {
599 ac.overlays.pickerMu.Unlock()
600 return
601 }
602 ac.overlays.pickerRemotePreview = candidate
603 ac.overlays.pickerRemotePreviewCancel = nil
604 ac.overlays.pickerMu.Unlock()
605 if sess := ac.currentAttachmentSession(); sess != nil {
606 d.invalidateRender(sess, ac, false, "remote picker preview")
607 }
608 }()
609}
610
611func remotePickerPreviewSize(size domain.Size) (uint16, uint16) {
612 layout := picker.ChooseLayout(size)
613 width, height := layout.Preview.Width, layout.Preview.Height
614 if width <= 0 || height <= 0 {
615 return 0, 0
616 }
617 return uint16(min(width, ports.RemotePreviewMaxWidth)), uint16(min(height, ports.RemotePreviewMaxHeight))
618}
619
620func staticRemotePickerPreview(width, height uint16, message string) picker.Preview {
621 if width == 0 || height == 0 {
622 return picker.Preview{}
623 }
624 rows := [][]renderer.Cell{make([]renderer.Cell, int(width))}
625 style := renderer.DefaultStyle()
626 for x := range rows[0] {
627 rows[0][x] = renderer.Cell{Rune: ' ', Style: style}
628 }
629 for x, r := range []rune(message) {
630 if x >= int(width) {
631 break
632 }
633 rows[0][x] = renderer.Cell{Rune: r, Style: style}
634 }
635 return picker.Preview{Rows: rows, Width: int(width), Height: len(rows)}
636}
637
638func pickerPreviewCurrent(ac *attachedClient, targetSess *session, next *tab, generation uint64) bool {
639 ac.overlays.pickerMu.Lock()
640 defer ac.overlays.pickerMu.Unlock()
641 return ac.overlays.pickerPreviewGeneration == generation && ac.overlays.pickerPreviewSession == targetSess && ac.overlays.pickerPreview == next
642}
643
644// revalidatePreviewSubscription removes a subscription that lost the picker
645// selection race after it was installed.
646func (d *Daemon) revalidatePreviewSubscription(ac *attachedClient, rc *renderCoordinator, targetSess *session, next *tab, generation uint64) {
647 if !pickerPreviewCurrent(ac, targetSess, next, generation) {
648 rc.teardownPreviewFor(ac, generation)
649 }
650}
651
652func (d *Daemon) teardownPreviewSubscription(ac *attachedClient, previous *session, generation uint64) {
653 if previous != nil {
654 if rc := previous.renderCoordinator(); rc != nil {
655 rc.teardownPreviewFor(ac, generation)
656 }
657 }
658}
659
660func (d *Daemon) unregisterPreview(ac *attachedClient) {
661 if ac == nil || ac.overlays == nil {
662 return
663 }
664 ac.overlays.pickerMu.Lock()
665 generation := ac.overlays.pickerPreviewGeneration
666 ac.overlays.pickerMu.Unlock()
667 d.clearPreviewGeneration(ac, generation)
668}
669
670// clearPreviewGeneration removes the preview only if it is still the observed
671// selection. Teardown paths may race a new picker selection.
672func (d *Daemon) clearPreviewGeneration(ac *attachedClient, generation uint64) bool {
673 ac.overlays.pickerMu.Lock()
674 if ac.overlays.pickerPreviewGeneration != generation {
675 ac.overlays.pickerMu.Unlock()
676 return false
677 }
678 previous := ac.overlays.pickerPreviewSession
679 ac.overlays.pickerPreviewGeneration++
680 if ac.overlays.pickerRemotePreviewCancel != nil {
681 ac.overlays.pickerRemotePreviewCancel()
682 ac.overlays.pickerRemotePreviewCancel = nil
683 }
684 ac.overlays.pickerPreviewSession = nil
685 ac.overlays.pickerRemotePreview = picker.Preview{}
686 ac.overlays.pickerPreview = nil
687 ac.overlays.pickerMu.Unlock()
688 d.teardownPreviewSubscription(ac, previous, generation)
689 return true
690}
691
692// clearDestroyedTabPreview clears coordinator-owned picker state for clients
693// that were previewing a removed tab. Tabs do not route render ownership.
694func (d *Daemon) clearDestroyedTabPreview(tb *tab) {
695 if d == nil || d.sessions == nil {
696 return
697 }
698 d.mu.Lock()
699 var clients []*attachedClient
700 for _, sess := range d.sessions {
701 if sess == nil {
702 continue
703 }
704 sess.mu.Lock()
705 clients = append(clients, sess.snapshotAttachmentsLocked()...)
706 sess.mu.Unlock()
707 }
708 d.mu.Unlock()
709 for _, ac := range clients {
710 if ac == nil || ac.overlays == nil {
711 continue
712 }
713 ac.overlays.pickerMu.Lock()
714 generation := ac.overlays.pickerPreviewGeneration
715 observed := ac.overlays.pickerPreview == tb
716 ac.overlays.pickerMu.Unlock()
717 if observed && d.clearPreviewGeneration(ac, generation) {
718 if sess := ac.currentAttachmentSession(); sess != nil {
719 d.invalidateRender(sess, ac, true, "picker.go")
720 }
721 }
722 }
723}
724
725func (d *Daemon) closePicker(ac *attachedClient) {
726 d.closePickerIfCurrent(ac, nil, 0)
727}
728
729// closePickerIfCurrent closes only the observed picker lifecycle. A nonzero
730// generation guards terminal parked cleanup even when a refresh replaced the
731// model within that generation.
732func (d *Daemon) closePickerIfCurrent(ac *attachedClient, model *picker.Model, generation uint64) bool {
733 if ac == nil || ac.overlays == nil {
734 return false
735 }
736 ac.overlays.pickerMu.Lock()
737 if model != nil && (ac.overlays.picker != model || ac.overlays.pickerGeneration != generation) || model == nil && generation != 0 && ac.overlays.pickerGeneration != generation {
738 ac.overlays.pickerMu.Unlock()
739 return false
740 }
741 instance := remotePickerInstance{ac: ac, generation: ac.overlays.pickerGeneration, model: ac.overlays.picker}
742 ac.overlays.picker = nil
743 ac.overlays.pickerTitle = ""
744 ac.overlays.pickerIntent = pickerNavigate
745 ac.overlays.pickerSource = moveSourceLocator{}
746 ac.overlays.pickerPending = nil
747 ac.overlays.pickerESC.stop()
748 previewGeneration := ac.overlays.pickerPreviewGeneration
749 ac.overlays.pickerMu.Unlock()
750 d.clearPreviewGeneration(ac, previewGeneration)
751 if instance.model != nil {
752 d.remotePickerClosed(instance)
753 }
754 return true
755}
756
757func pickerTargetsEqual(left, right picker.Target) bool {
758 if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||
759 left.RemoteHost != right.RemoteHost || left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped ||
760 left.UnavailableReason != right.UnavailableReason || !remoteKeysEqual(left.RemoteKey, right.RemoteKey) ||
761 !remoteTargetsEqual(left.RemoteTarget, right.RemoteTarget) {
762 return false
763 }
764 if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil {
765 return left.ExpectedCreatedAt == nil && right.ExpectedCreatedAt == nil
766 }
767 return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt
768}
769
770func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool {
771 if left == nil || right == nil {
772 return left == nil && right == nil
773 }
774 return *left == *right
775}
776
777func remoteTargetsEqual(left, right *domain.RemoteSessionTarget) bool {
778 if left == nil || right == nil {
779 return left == nil && right == nil
780 }
781 return *left == *right
782}
783
784func (d *Daemon) sessionByID(id domain.SessionID) *session {
785 d.mu.Lock()
786 defer d.mu.Unlock()
787 return d.sessions[id]
788}
789
790// sessionHandoffGuard optionally constrains an active-session handoff to the
791// tab that initiated navigation. Its zero value preserves ordinary picker and
792// command switching behavior.
793type sessionHandoffGuard struct {
794 expectedSource *tab
795 closePicker bool
796}
797
798// switchActiveTargetForAttachment hands a frame-bound navigation request to the
799// centralized transition. The transition releases admission at its freeze seam
800// and revalidates the exact initiating token before changing target focus or
801// attachment ownership.
802func (d *Daemon) switchActiveTargetForAttachment(token attachmentConnectionToken, target picker.Target) error {
803 return d.switchActiveTargetForAttachmentGuarded(token, target, sessionHandoffGuard{}, "jump-attention")
804}
805
806func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {
807 fence := &attachmentLifecycleFence{}
808 if target.ExpectedCreatedAt != nil {
809 fence.name = target.Name
810 fence.createdAt = *target.ExpectedCreatedAt
811 fence.checkCreatedAt = true
812 }
813 if target.Incarnation != (domain.IncarnationID{}) {
814 fence.incarnation = target.Incarnation
815 fence.checkIncarnation = true
816 }
817 if target.TabID != "" {
818 fence.tabID = target.TabID
819 fence.tabIndex = target.TabIndex
820 fence.checkTab = true
821 }
822 if !fence.checkCreatedAt && !fence.checkIncarnation && !fence.checkTab {
823 return nil
824 }
825 return fence
826}
827
828func (d *Daemon) switchActiveTargetForAttachmentGuarded(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
829 if token.sess == nil || token.ac == nil {
830 return nil
831 }
832 d.mu.Lock()
833 targetSess := d.sessions[target.Session]
834 d.mu.Unlock()
835 if targetSess == nil {
836 if !token.attachmentCurrent() {
837 return nil
838 }
839 d.invalidateRender(token.sess, token.ac, true, "picker.go")
840 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
841 }
842 if targetSess == token.sess {
843 return nil
844 }
845
846 transition, err := d.transitionAttachment(attachmentTransitionRequest{
847 source: token.sess, target: targetSess, next: token.ac,
848
849 expectedTransport: token.transport, sourceToken: &token, action: action,
850 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
851 activateTargetTab: true, targetTabIndex: target.TabIndex, copySourceEnvironment: true, ready: true,
852 })
853 if err != nil {
854 // Losing the exact source role is a benign stale action, not a notice for
855 // whichever attachment replaced the initiator.
856 if !token.attachmentCurrent() {
857 return nil
858 }
859 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
860 }
861 if guard.closePicker {
862 fresh, admitted := token.ac.beginAttachmentEffect(transition.published)
863 if admitted {
864 d.closePicker(token.ac)
865 fresh.End()
866 }
867 }
868 d.touchMRU(targetSess)
869 d.deferAttachmentTransitionCleanups(transition)
870 d.firstPaintForTransition(transition.published)
871 return nil
872}
873
874// switchToTarget resolves named lifecycle targets and commits their transition
875// while d.mu is held. A named palette result is allowed to cross an
876// active/stopped transition, but it never follows a same-name replacement.
877func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error {
878 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
879}
880
881// switchToTargetForAttachment is the sole client-originated navigation entry. The
882// intent captures the exact initiating capability before admission is released;
883// every active, stopped, and same-session target then uses transitionAttachment
884// for frozen, atomic source-token preflight.
885func (d *Daemon) switchToTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
886 if token.sess == nil || token.ac == nil || token.effect == nil {
887 return nil
888 }
889 if target.RemoteTarget != nil {
890 var key domain.RemoteSessionKey
891 if target.RemoteKey != nil {
892 key = *target.RemoteKey
893 }
894 return d.sendRemoteAttachTargetForAttachment(token, target, key, guard, action)
895 }
896 if target.RemoteKey != nil {
897 return d.sendRemoteAttachTargetForAttachment(token, target, *target.RemoteKey, guard, action)
898 }
899 token.effect.bindActionEnd(d, action)
900 token.effect.End()
901 if !token.attachmentCurrent() {
902 return errAttachmentTransition
903 }
904 return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
905}
906
907// sendRemoteAttachTargetForAttachment validates the catalog row and hands the
908// endpoint to the thin client. The daemon owns no remote session shadow: after
909// the target is sent, the local attachment is detached and the client opens a
910// fresh connection to the owning daemon.
911func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
912 failUnavailable := func() error {
913 if token.attachmentCurrent() {
914 d.notifyRemotePickerUnavailable(token.sess, target)
915 }
916 return errAttachmentTransition
917 }
918 var handoff ports.AttachTarget
919 if target.RemoteTarget != nil {
920 remoteTarget := *target.RemoteTarget
921 if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
922 return failUnavailable()
923 }
924 if target.RemoteKey != nil {
925 if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
926 return failUnavailable()
927 }
928 }
929 handoff = ports.AttachTarget{
930 Endpoint: remoteTarget.Endpoint,
931 Session: remoteTarget.SessionName,
932 Intent: ports.IntentAttach,
933 RemoteTarget: &remoteTarget,
934 EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
935 }
936 } else {
937 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
938 return failUnavailable()
939 }
940 handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}
941 }
942 payload := ports.MarshalAttachTarget(handoff)
943 if payload == nil {
944 return failUnavailable()
945 }
946 if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
947 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)
948 }
949 if guard.closePicker {
950 d.closePicker(token.ac)
951 }
952 d.clientGoneForAttachment(token, false)
953 return nil
954}
955
956func (d *Daemon) remoteCatalogEntryLocked(host string) (ports.RemoteCatalogCacheEntry, bool) {
957 if d.remoteCatalog.status[host] != remoteHostFresh {
958 return ports.RemoteCatalogCacheEntry{}, false
959 }
960 entry, ok := d.remoteCatalog.cache[host]
961 if !ok || remoteCatalogExpired(entry.FetchedAt, d.clock.Now()) {
962 d.remoteCatalog.status[host] = remoteHostStale
963 return ports.RemoteCatalogCacheEntry{}, false
964 }
965 return entry, true
966}
967
968func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
969 if d == nil {
970 return false
971 }
972 d.remoteCatalog.mu.Lock()
973 defer d.remoteCatalog.mu.Unlock()
974 entry, ok := d.remoteCatalogEntryLocked(key.Host)
975 if !ok {
976 return false
977 }
978 for _, session := range entry.Sessions {
979 if session.Name == key.Name {
980 return session.State == "up"
981 }
982 }
983 return false
984}
985
986func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
987 if d == nil || target.Validate() != nil {
988 return false
989 }
990 d.remoteCatalog.mu.Lock()
991 defer d.remoteCatalog.mu.Unlock()
992 entry, ok := d.remoteCatalogEntryLocked(target.Endpoint)
993 if !ok {
994 return false
995 }
996 for _, session := range entry.Sessions {
997 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
998 continue
999 }
1000 if target.Stopped != remoteSessionStateStopped(session.State) || session.State == "broken" {
1001 return false
1002 }
1003 tabs := ports.CatalogTabs(session)
1004 metadata := make([]domain.TabSelectorTab, 0, len(tabs))
1005 for _, tab := range tabs {
1006 metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name})
1007 }
1008 if target.Stopped {
1009 _, ok := target.StoppedTab.Resolve(metadata)
1010 return ok
1011 }
1012 found := false
1013 for _, tab := range tabs {
1014 if tab.ID != string(target.LiveTabID) {
1015 continue
1016 }
1017 if found {
1018 return false
1019 }
1020 found = true
1021 }
1022 return found
1023 }
1024 return false
1025}
1026
1027// switchToTargetGuarded is retained for daemon-internal and headless callers.
1028func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
1029 return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "")
1030}
1031
1032func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) error {
1033 if target.Name != "" {
1034 ctx := d.serveCtx
1035 if ctx == nil {
1036 ctx = context.Background()
1037 }
1038 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
1039 if sourceToken != nil && !sourceToken.attachmentCurrent() {
1040 return errAttachmentTransition
1041 }
1042 d.invalidateRender(from, ac, true, "picker.go")
1043 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
1044 }
1045 }
1046
1047 d.mu.Lock()
1048 var (
1049 targetSess *session
1050 transition attachmentTransitionResult
1051 switched bool
1052 cause error
1053 )
1054 if target.Name != "" {
1055 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
1056 if ok {
1057 if isStopped {
1058 targetSess, transition, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, sourceToken, guard, action)
1059 } else {
1060 // The snapshot ID may describe the stopped representation. The
1061 // locked name/lifecycle resolver chose this active incarnation.
1062 resolvedTarget := target
1063 resolvedTarget.Session = active.id
1064 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)
1065 }
1066 }
1067 } else if active := d.sessions[target.Session]; active != nil {
1068 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)
1069 }
1070 d.mu.Unlock()
1071
1072 if !switched {
1073 if sourceToken != nil && !sourceToken.attachmentCurrent() {
1074 return errAttachmentTransition
1075 }
1076 if guard.expectedSource != nil {
1077 return errNoNeighbor
1078 }
1079 d.invalidateRender(from, ac, true, "picker.go")
1080 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
1081 }
1082 if guard.closePicker {
1083 if sourceToken != nil {
1084 fresh, admitted := ac.beginAttachmentEffect(transition.published)
1085 if admitted {
1086 d.closePicker(ac)
1087 fresh.End()
1088 }
1089 } else {
1090 d.closePicker(ac)
1091 }
1092 }
1093 d.deferAttachmentTransitionCleanups(transition)
1094 if targetSess == from {
1095 if sourceToken != nil {
1096 fresh, admitted := ac.beginAttachmentEffect(transition.published)
1097 if admitted {
1098 d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease)
1099 d.invalidateRender(from, ac, true, "picker.go")
1100 fresh.End()
1101 }
1102 } else {
1103 d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, nil)
1104 d.invalidateRender(from, ac, true, "picker.go")
1105 }
1106 } else {
1107 d.firstPaintForTransition(transition.published)
1108 }
1109 return nil
1110}
1111
1112// resolveNamedLifecycleTargetLocked chooses exactly one current representation
1113// of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle
1114// identity here closes the lookup-to-handoff window for palette targets.
1115func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, stoppedSession, bool, bool) {
1116 if target.Name == "" {
1117 return nil, stoppedSession{}, false, false
1118 }
1119 if active := d.findByNameLocked(target.Name); active != nil {
1120 active.mu.Lock()
1121 matches := targetMatchesLifecycle(target, active.name, active.createdAt)
1122 active.mu.Unlock()
1123 return active, stoppedSession{}, false, matches
1124 }
1125 stopped, ok := d.stopped[target.Name]
1126 if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
1127 return nil, stoppedSession{}, false, false
1128 }
1129 return nil, stopped, true, true
1130}
1131
1132// switchToActiveTargetLocked commits an active target handoff through the
1133// centralized attachment transition. Caller holds d.mu.
1134func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) (*session, attachmentTransitionResult, bool) {
1135 if d.sessions[target.Session] != targetSess {
1136 return nil, attachmentTransitionResult{}, false
1137 }
1138 if targetSess == from {
1139 if sourceToken == nil {
1140 targetSess.mu.Lock()
1141 defer targetSess.mu.Unlock()
1142 _, attached := targetSess.attachments[ac]
1143 if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
1144 return nil, attachmentTransitionResult{}, false
1145 }
1146 if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {
1147 return nil, attachmentTransitionResult{}, false
1148 }
1149 return targetSess, attachmentTransitionResult{}, true
1150 }
1151 d.mu.Unlock()
1152 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1153 source: from, target: targetSess, next: ac,
1154
1155 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
1156 activateTargetTab: true, targetTabIndex: target.TabIndex, preserveAttachment: true,
1157 })
1158 d.mu.Lock()
1159 return targetSess, transition, err == nil
1160 }
1161
1162 unlock := lockAttachmentSessions(from, targetSess)
1163 _, sourceAttached := from.attachments[ac]
1164 if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {
1165 unlock()
1166 return nil, attachmentTransitionResult{}, false
1167 }
1168 if guard.expectedSource != nil {
1169 view := ac.viewSnapshot()
1170 if domain.TabStableID(guard.expectedSource.stableID) != view.tabID {
1171 unlock()
1172 return nil, attachmentTransitionResult{}, false
1173 }
1174 guard.expectedSource.mu.Lock()
1175 sourceHasFloating := guard.expectedSource.floating.state == floatingVisible
1176 guard.expectedSource.mu.Unlock()
1177 if sourceHasFloating {
1178 unlock()
1179 return nil, attachmentTransitionResult{}, false
1180 }
1181 }
1182 unlock()
1183
1184 // Release d.mu before the gate freeze/drain. transitionAttachment reacquires
1185 // d.mu and revalidates both registered lifecycles for publication.
1186 d.mu.Unlock()
1187 expectedTransport := ac.transportSnapshot()
1188 if sourceToken != nil {
1189 expectedTransport = sourceToken.transport
1190 }
1191 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1192 source: from,
1193 target: targetSess,
1194 next: ac,
1195
1196 expectedTransport: expectedTransport,
1197 sourceToken: sourceToken,
1198 action: action,
1199 expectedSourceTab: guard.expectedSource,
1200 activateTargetTab: target.TabIndex >= 0,
1201 targetTabIndex: target.TabIndex,
1202 copySourceEnvironment: true,
1203 ready: true,
1204 })
1205 d.mu.Lock()
1206 if err != nil {
1207 return nil, attachmentTransitionResult{}, false
1208 }
1209 d.touchMRU(targetSess)
1210 return targetSess, transition, true
1211}
1212
1213// resumeStoppedAndSwitchLocked creates the stopped representation and commits
1214// the handoff while d.mu is held. Creation failure leaves the source client
1215// and stopped record untouched.
1216func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentConnectionToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
1217 if stopped.record.Name != "" && stopped.state == ports.SessionBroken {
1218 return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
1219 }
1220
1221 if sourceToken != nil {
1222 var targetSess *session
1223 d.mu.Unlock()
1224 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1225 source: from, next: ac,
1226 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
1227 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
1228 createTargetLocked: func() (*session, error) {
1229 current, ok := d.stopped[target.Name]
1230 if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) {
1231 return nil, errAttachmentTransition
1232 }
1233 from.mu.Lock()
1234 cwd, term, env := d.dirOrHome(current.cwd), from.terminal, copyEnvironment(from.env)
1235 from.mu.Unlock()
1236 created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, current.tabNames)
1237 targetSess = created
1238 return created, createErr
1239 },
1240 })
1241 d.mu.Lock()
1242 if err != nil {
1243 if targetSess != nil {
1244 d.mu.Unlock()
1245 _ = d.killSession(targetSess, ports.ReasonSessionKilled, true)
1246 d.mu.Lock()
1247 }
1248 return targetSess, attachmentTransitionResult{}, false, err
1249 }
1250 d.touchMRU(targetSess)
1251 return targetSess, transition, true, nil
1252 }
1253
1254 from.mu.Lock()
1255 _, attached := from.attachments[ac]
1256 if !attached {
1257 from.mu.Unlock()
1258 return nil, attachmentTransitionResult{}, false, nil
1259 }
1260 term := from.terminal
1261 env := copyEnvironment(from.env)
1262 from.mu.Unlock()
1263 cwd := d.dirOrHome(stopped.cwd)
1264 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, stopped.tabNames)
1265 if err != nil {
1266 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
1267 return nil, attachmentTransitionResult{}, false, err
1268 }
1269
1270 d.mu.Unlock()
1271 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1272 source: from, target: targetSess, next: ac,
1273
1274 expectedTransport: ac.transportSnapshot(), ready: true,
1275 })
1276 d.mu.Lock()
1277 if err != nil {
1278 return targetSess, attachmentTransitionResult{}, false, err
1279 }
1280 d.touchMRU(targetSess)
1281 return targetSess, transition, true, nil
1282}
1283
1284func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
1285 return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)
1286}
1287
1288// stealClientForTarget is retained for direct-ID callers and tests. Named
1289// targets must use switchToTarget so resolution and commit share d.mu.
1290func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) {
1291 d.mu.Lock()
1292 _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
1293 d.mu.Unlock()
1294 if !switched {
1295 return
1296 }
1297 d.deferAttachmentTransitionCleanups(transition)
1298}
1299
1300// resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
1301// its stopped target and commits creation under one d.mu critical section.
1302func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
1303 d.mu.Lock()
1304 stopped, ok := d.stopped[target.Name]
1305 var (
1306 transition attachmentTransitionResult
1307 switched bool
1308 )
1309 if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
1310 _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")
1311 }
1312 d.mu.Unlock()
1313 if !switched {
1314 d.invalidateRender(from, ac, true, "picker.go")
1315 return false
1316 }
1317 d.deferAttachmentTransitionCleanups(transition)
1318 d.firstPaintForTransition(transition.published)
1319 return true
1320}
1321
1322func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attachmentConnectionToken) error {
1323 if token.ac == nil {
1324 return d.killPickerTarget(target)
1325 }
1326 if target.RemoteKey != nil {
1327 return nil
1328 }
1329 if target.Stopped {
1330 token.effect.bindActionEnd(d, "picker-delete")
1331 token.effect.End()
1332 frozen := freezeAttachmentEffectGates(token.ac)
1333 defer frozen.unfreeze()
1334 if !d.sourceAttachmentTokenCurrentFrozen(token) {
1335 return nil
1336 }
1337 return d.killPickerTarget(target)
1338 }
1339 d.mu.Lock()
1340 targetSess := d.sessions[target.Session]
1341 d.mu.Unlock()
1342 if targetSess == nil {
1343 return nil
1344 }
1345 return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
1346}
1347
1348func (d *Daemon) killPickerTarget(target picker.Target) error {
1349 if target.Stopped {
1350 if err := d.retryStoppedPurge(target.Name); err != nil {
1351 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
1352 return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)
1353 }
1354 return nil
1355 }
1356 d.mu.Lock()
1357 targetSess := d.sessions[target.Session]
1358 d.mu.Unlock()
1359 if targetSess != nil {
1360 return d.killSession(targetSess, ports.ReasonSessionKilled, true)
1361 }
1362 return nil
1363}
1364
1365func snapshotPickerPreview(tb *tab) picker.Preview {
1366 if tb == nil {
1367 return picker.Preview{}
1368 }
1369 tb.mu.Lock()
1370 layoutSnap := solveTabLayoutLocked(tb)
1371 if !layoutSnap.ok {
1372 p := tb.focusedPane()
1373 if p == nil {
1374 tb.mu.Unlock()
1375 return picker.Preview{}
1376 }
1377 p.mu.Lock()
1378 preview := pickerPreviewFromLockedPane(p)
1379 p.mu.Unlock()
1380 tb.mu.Unlock()
1381 return preview
1382 }
1383 state := capturedRenderState{layout: capturedTabLayout{
1384 area: layoutSnap.area, focus: layoutSnap.focus,
1385 placements: append([]layout.Placement(nil), layoutSnap.placements...),
1386 dividers: append([]layout.Divider(nil), layoutSnap.dividers...),
1387 fingerprint: layoutSnap.fingerprint, valid: true,
1388 }}
1389 for _, placement := range layoutSnap.placements {
1390 p := tb.panes[placement.ID]
1391 if p == nil {
1392 continue
1393 }
1394 visible := placement.Content
1395 if placement.Collapsed {
1396 visible = domain.Rect{}
1397 }
1398 p.mu.Lock()
1399 captured := capturePaneRenderStateLocked(p, visible)
1400 p.mu.Unlock()
1401 captured.placement = placement
1402 captured.focused = placement.ID == layoutSnap.focus
1403 state.panes = append(state.panes, captured)
1404 }
1405 tb.mu.Unlock()
1406 return pickerPreviewFromCapturedRender(state)
1407}
1408
1409func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {
1410 composed := composeFrame(state, composeCacheInput{}, composeCacheInput{}).frame
1411 if composed.Height < 2 {
1412 return pickerPreviewFromFrame(composed)
1413 }
1414 rows := make([][]renderer.Cell, composed.Height-2)
1415 for y := range rows {
1416 rows[y] = append([]renderer.Cell(nil), composed.Row(y+1)...)
1417 }
1418 return picker.Preview{Rows: rows, Width: composed.Width, Height: len(rows)}
1419}
1420
1421func pickerPreviewFromLockedPane(p *pane) picker.Preview {
1422 return pickerPreviewFromFrame(p.screen.Frame)
1423}
1424
1425func pickerPreviewFromFrame(frame renderer.Frame) picker.Preview {
1426 rows := make([][]renderer.Cell, frame.Height)
1427 for y := range rows {
1428 rows[y] = append([]renderer.Cell(nil), frame.Row(y)...)
1429 }
1430 return picker.Preview{Rows: rows, Width: frame.Width, Height: frame.Height}
1431}