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 return d.sendLocalAttachTargetForAttachment(token, target, guard, action)
900}
901
902// sendLocalAttachTargetForAttachment makes local picker navigation follow the
903// same client-owned handoff path as remote navigation. The client reconnects
904// to this daemon and restores its per-route tab from the route ledger.
905func (d *Daemon) sendLocalAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, guard sessionHandoffGuard, action string) error {
906 d.mu.Lock()
907 var targetSess *session
908 var sessionName string
909 var exactTarget *ports.ExactSessionTarget
910 var matches bool
911 if target.Name != "" {
912 var stopped stoppedSession
913 var stoppedTarget bool
914 targetSess, stopped, stoppedTarget, matches = d.resolveNamedLifecycleTargetLocked(target)
915 if stoppedTarget && matches {
916 sessionName = stopped.name
917 exactTarget = &ports.ExactSessionTarget{LifecycleID: stopped.incarnation, SessionName: sessionName}
918 }
919 } else {
920 targetSess = d.sessions[target.Session]
921 matches = targetSess != nil
922 }
923 if matches && targetSess != nil {
924 targetSess.mu.Lock()
925 sessionName = targetSess.name
926 exactTarget = &ports.ExactSessionTarget{LifecycleID: targetSess.incarnation, SessionName: sessionName}
927 targetSess.mu.Unlock()
928 }
929 d.mu.Unlock()
930 if !matches || !token.attachmentCurrent() {
931 return errAttachmentTransition
932 }
933 if target.TabID != "" {
934 // An explicit tab row is a direct user choice, not route memory; retain
935 // the daemon-side transition so it opens exactly that tab.
936 return d.switchToTargetGuardedForAttachment(token.sess, token.ac, target, guard, &token, action)
937 }
938
939 payload := ports.MarshalAttachTarget(ports.AttachTarget{
940 Session: sessionName,
941 Intent: ports.IntentAttach,
942 ExactTarget: exactTarget,
943 EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
944 })
945 if payload == nil {
946 return errAttachmentTransition
947 }
948 if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
949 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to local session", err)
950 }
951 if guard.closePicker {
952 d.closePicker(token.ac)
953 }
954 d.clientGoneForAttachment(token, false)
955 return nil
956}
957
958// sendRemoteAttachTargetForAttachment validates the catalog row and hands the
959// endpoint to the thin client. The daemon owns no remote session shadow: after
960// the target is sent, the local attachment is detached and the client opens a
961// fresh connection to the owning daemon.
962func (d *Daemon) sendRemoteAttachTargetForAttachment(token attachmentConnectionToken, target picker.Target, key domain.RemoteSessionKey, guard sessionHandoffGuard, _ string) error {
963 failUnavailable := func() error {
964 if token.attachmentCurrent() {
965 d.notifyRemotePickerUnavailable(token.sess, target)
966 }
967 return errAttachmentTransition
968 }
969 var handoff ports.AttachTarget
970 if target.RemoteTarget != nil {
971 remoteTarget := *target.RemoteTarget
972 if err := remoteTarget.Validate(); err != nil || target.Stopped != remoteTarget.Stopped || !d.remotePickerTargetReadyTarget(remoteTarget) {
973 return failUnavailable()
974 }
975 if target.RemoteKey != nil {
976 if target.Session != target.RemoteKey.ID() || target.RemoteKey.Host != remoteTarget.Endpoint || target.RemoteKey.Name != remoteTarget.SessionName || target.RemoteKey.LifecycleID != remoteTarget.LifecycleID {
977 return failUnavailable()
978 }
979 }
980 handoff = ports.AttachTarget{
981 Endpoint: remoteTarget.Endpoint,
982 Session: remoteTarget.SessionName,
983 Intent: ports.IntentAttach,
984 RemoteTarget: &remoteTarget,
985 EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned,
986 }
987 } else {
988 if key.Validate() != nil || target.Session != key.ID() || target.Stopped || target.Name != "" || !d.remotePickerTargetReady(key) {
989 return failUnavailable()
990 }
991 handoff = ports.AttachTarget{Endpoint: key.Host, Session: key.Name, Intent: ports.IntentAttach, EnvironmentPolicy: ports.EnvironmentPolicyDaemonOwned}
992 }
993 payload := ports.MarshalAttachTarget(handoff)
994 if payload == nil {
995 return failUnavailable()
996 }
997 if err := token.sendControl(ports.Frame{Type: ports.MsgAttachTarget, Payload: payload}); err != nil {
998 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't attach to remote session", err)
999 }
1000 if guard.closePicker {
1001 d.closePicker(token.ac)
1002 }
1003 d.clientGoneForAttachment(token, false)
1004 return nil
1005}
1006
1007func (d *Daemon) remoteCatalogEntryLocked(host string) (ports.RemoteCatalogCacheEntry, bool) {
1008 if d.remoteCatalog.status[host] != remoteHostFresh {
1009 return ports.RemoteCatalogCacheEntry{}, false
1010 }
1011 entry, ok := d.remoteCatalog.cache[host]
1012 if !ok || remoteCatalogExpired(entry.FetchedAt, d.clock.Now()) {
1013 d.remoteCatalog.status[host] = remoteHostStale
1014 return ports.RemoteCatalogCacheEntry{}, false
1015 }
1016 return entry, true
1017}
1018
1019func (d *Daemon) remotePickerTargetReady(key domain.RemoteSessionKey) bool {
1020 if d == nil {
1021 return false
1022 }
1023 d.remoteCatalog.mu.Lock()
1024 defer d.remoteCatalog.mu.Unlock()
1025 entry, ok := d.remoteCatalogEntryLocked(key.Host)
1026 if !ok {
1027 return false
1028 }
1029 for _, session := range entry.Sessions {
1030 if session.Name == key.Name {
1031 return session.State == "up"
1032 }
1033 }
1034 return false
1035}
1036
1037func (d *Daemon) remotePickerTargetReadyTarget(target domain.RemoteSessionTarget) bool {
1038 if d == nil || target.Validate() != nil {
1039 return false
1040 }
1041 d.remoteCatalog.mu.Lock()
1042 defer d.remoteCatalog.mu.Unlock()
1043 entry, ok := d.remoteCatalogEntryLocked(target.Endpoint)
1044 if !ok {
1045 return false
1046 }
1047 for _, session := range entry.Sessions {
1048 if session.Name != target.SessionName || session.LifecycleID != target.LifecycleID {
1049 continue
1050 }
1051 if target.Stopped != remoteSessionStateStopped(session.State) || session.State == "broken" {
1052 return false
1053 }
1054 tabs := ports.CatalogTabs(session)
1055 metadata := make([]domain.TabSelectorTab, 0, len(tabs))
1056 for _, tab := range tabs {
1057 metadata = append(metadata, domain.TabSelectorTab{ID: domain.TabStableID(tab.ID), Name: tab.Name})
1058 }
1059 if target.Stopped {
1060 _, ok := target.StoppedTab.Resolve(metadata)
1061 return ok
1062 }
1063 found := false
1064 for _, tab := range tabs {
1065 if tab.ID != string(target.LiveTabID) {
1066 continue
1067 }
1068 if found {
1069 return false
1070 }
1071 found = true
1072 }
1073 return found
1074 }
1075 return false
1076}
1077
1078// switchToTargetGuarded is retained for daemon-internal and headless callers.
1079func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
1080 return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "")
1081}
1082
1083func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) error {
1084 if target.Name != "" {
1085 ctx := d.serveCtx
1086 if ctx == nil {
1087 ctx = context.Background()
1088 }
1089 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
1090 if sourceToken != nil && !sourceToken.attachmentCurrent() {
1091 return errAttachmentTransition
1092 }
1093 d.invalidateRender(from, ac, true, "picker.go")
1094 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
1095 }
1096 }
1097
1098 d.mu.Lock()
1099 var (
1100 targetSess *session
1101 transition attachmentTransitionResult
1102 switched bool
1103 cause error
1104 )
1105 if target.Name != "" {
1106 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
1107 if ok {
1108 if isStopped {
1109 targetSess, transition, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, sourceToken, guard, action)
1110 } else {
1111 // The snapshot ID may describe the stopped representation. The
1112 // locked name/lifecycle resolver chose this active incarnation.
1113 resolvedTarget := target
1114 resolvedTarget.Session = active.id
1115 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceToken, action)
1116 }
1117 }
1118 } else if active := d.sessions[target.Session]; active != nil {
1119 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceToken, action)
1120 }
1121 d.mu.Unlock()
1122
1123 if !switched {
1124 if sourceToken != nil && !sourceToken.attachmentCurrent() {
1125 return errAttachmentTransition
1126 }
1127 if guard.expectedSource != nil {
1128 return errNoNeighbor
1129 }
1130 d.invalidateRender(from, ac, true, "picker.go")
1131 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
1132 }
1133 if guard.closePicker {
1134 if sourceToken != nil {
1135 fresh, admitted := ac.beginAttachmentEffect(transition.published)
1136 if admitted {
1137 d.closePicker(ac)
1138 fresh.End()
1139 }
1140 } else {
1141 d.closePicker(ac)
1142 }
1143 }
1144 d.deferAttachmentTransitionCleanups(transition)
1145 if targetSess == from {
1146 if sourceToken != nil {
1147 fresh, admitted := ac.beginAttachmentEffect(transition.published)
1148 if admitted {
1149 d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease)
1150 d.invalidateRender(from, ac, true, "picker.go")
1151 fresh.End()
1152 }
1153 } else {
1154 d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, nil)
1155 d.invalidateRender(from, ac, true, "picker.go")
1156 }
1157 } else {
1158 d.firstPaintForTransition(transition.published)
1159 }
1160 return nil
1161}
1162
1163// resolveNamedLifecycleTargetLocked chooses exactly one current representation
1164// of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle
1165// identity here closes the lookup-to-handoff window for palette targets.
1166func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, stoppedSession, bool, bool) {
1167 if target.Name == "" {
1168 return nil, stoppedSession{}, false, false
1169 }
1170 if active := d.findByNameLocked(target.Name); active != nil {
1171 active.mu.Lock()
1172 matches := targetMatchesLifecycle(target, active.name, active.createdAt)
1173 active.mu.Unlock()
1174 return active, stoppedSession{}, false, matches
1175 }
1176 stopped, ok := d.stopped[target.Name]
1177 if !ok || stopped.purging || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
1178 return nil, stoppedSession{}, false, false
1179 }
1180 return nil, stopped, true, true
1181}
1182
1183// switchToActiveTargetLocked commits an active target handoff through the
1184// centralized attachment transition. Caller holds d.mu.
1185func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceToken *attachmentConnectionToken, action string) (*session, attachmentTransitionResult, bool) {
1186 if d.sessions[target.Session] != targetSess {
1187 return nil, attachmentTransitionResult{}, false
1188 }
1189 if targetSess == from {
1190 if sourceToken == nil {
1191 targetSess.mu.Lock()
1192 defer targetSess.mu.Unlock()
1193 _, attached := targetSess.attachments[ac]
1194 if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
1195 return nil, attachmentTransitionResult{}, false
1196 }
1197 if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {
1198 return nil, attachmentTransitionResult{}, false
1199 }
1200 return targetSess, attachmentTransitionResult{}, true
1201 }
1202 d.mu.Unlock()
1203 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1204 source: from, target: targetSess, next: ac,
1205
1206 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
1207 activateTargetTab: true, targetTabIndex: target.TabIndex, preserveAttachment: true,
1208 })
1209 d.mu.Lock()
1210 return targetSess, transition, err == nil
1211 }
1212
1213 unlock := lockAttachmentSessions(from, targetSess)
1214 _, sourceAttached := from.attachments[ac]
1215 if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt) {
1216 unlock()
1217 return nil, attachmentTransitionResult{}, false
1218 }
1219 if guard.expectedSource != nil {
1220 view := ac.viewSnapshot()
1221 if domain.TabStableID(guard.expectedSource.stableID) != view.tabID {
1222 unlock()
1223 return nil, attachmentTransitionResult{}, false
1224 }
1225 guard.expectedSource.mu.Lock()
1226 sourceHasFloating := guard.expectedSource.floating.state == floatingVisible
1227 guard.expectedSource.mu.Unlock()
1228 if sourceHasFloating {
1229 unlock()
1230 return nil, attachmentTransitionResult{}, false
1231 }
1232 }
1233 unlock()
1234
1235 // Release d.mu before the gate freeze/drain. transitionAttachment reacquires
1236 // d.mu and revalidates both registered lifecycles for publication.
1237 d.mu.Unlock()
1238 expectedTransport := ac.transportSnapshot()
1239 if sourceToken != nil {
1240 expectedTransport = sourceToken.transport
1241 }
1242 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1243 source: from,
1244 target: targetSess,
1245 next: ac,
1246
1247 expectedTransport: expectedTransport,
1248 sourceToken: sourceToken,
1249 action: action,
1250 expectedSourceTab: guard.expectedSource,
1251 activateTargetTab: target.TabIndex >= 0,
1252 targetTabIndex: target.TabIndex,
1253 copySourceEnvironment: true,
1254 ready: true,
1255 })
1256 d.mu.Lock()
1257 if err != nil {
1258 return nil, attachmentTransitionResult{}, false
1259 }
1260 d.touchMRU(targetSess)
1261 return targetSess, transition, true
1262}
1263
1264// resumeStoppedAndSwitchLocked creates the stopped representation and commits
1265// the handoff while d.mu is held. Creation failure leaves the source client
1266// and stopped record untouched.
1267func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped stoppedSession, sourceToken *attachmentConnectionToken, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
1268 if stopped.record.Name != "" && stopped.state == ports.SessionBroken {
1269 return nil, attachmentTransitionResult{}, false, &protoErr{ports.ErrInternal, "session durable state is broken: " + target.Name}
1270 }
1271
1272 if sourceToken != nil {
1273 var targetSess *session
1274 d.mu.Unlock()
1275 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1276 source: from, next: ac,
1277 expectedTransport: sourceToken.transport, sourceToken: sourceToken, action: action,
1278 expectedSourceTab: guard.expectedSource, copySourceEnvironment: true, ready: true,
1279 createTargetLocked: func() (*session, error) {
1280 current, ok := d.stopped[target.Name]
1281 if !ok || current.purging || current.createdAt != stopped.createdAt || !targetMatchesLifecycle(target, current.name, current.createdAt) {
1282 return nil, errAttachmentTransition
1283 }
1284 from.mu.Lock()
1285 cwd, term, env := d.dirOrHome(current.cwd), from.terminal, copyEnvironment(from.env)
1286 from.mu.Unlock()
1287 created, createErr := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, current.tabNames)
1288 targetSess = created
1289 return created, createErr
1290 },
1291 })
1292 d.mu.Lock()
1293 if err != nil {
1294 if targetSess != nil {
1295 d.mu.Unlock()
1296 _ = d.killSession(targetSess, ports.ReasonSessionKilled, true)
1297 d.mu.Lock()
1298 }
1299 return targetSess, attachmentTransitionResult{}, false, err
1300 }
1301 d.touchMRU(targetSess)
1302 return targetSess, transition, true, nil
1303 }
1304
1305 from.mu.Lock()
1306 _, attached := from.attachments[ac]
1307 if !attached {
1308 from.mu.Unlock()
1309 return nil, attachmentTransitionResult{}, false, nil
1310 }
1311 term := from.terminal
1312 env := copyEnvironment(from.env)
1313 from.mu.Unlock()
1314 cwd := d.dirOrHome(stopped.cwd)
1315 targetSess, err := d.createSessionLocked(target.Name, false, cwd, ac.sizeSnapshot(), term, env, stopped.tabNames)
1316 if err != nil {
1317 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
1318 return nil, attachmentTransitionResult{}, false, err
1319 }
1320
1321 d.mu.Unlock()
1322 transition, err := d.transitionAttachment(attachmentTransitionRequest{
1323 source: from, target: targetSess, next: ac,
1324
1325 expectedTransport: ac.transportSnapshot(), ready: true,
1326 })
1327 d.mu.Lock()
1328 if err != nil {
1329 return targetSess, attachmentTransitionResult{}, false, err
1330 }
1331 d.touchMRU(targetSess)
1332 return targetSess, transition, true, nil
1333}
1334
1335func targetMatchesLifecycle(target picker.Target, name string, createdAt int64) bool {
1336 return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)
1337}
1338
1339// stealClientForTarget is retained for direct-ID callers and tests. Named
1340// targets must use switchToTarget so resolution and commit share d.mu.
1341func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) {
1342 d.mu.Lock()
1343 _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
1344 d.mu.Unlock()
1345 if !switched {
1346 return
1347 }
1348 d.deferAttachmentTransitionCleanups(transition)
1349}
1350
1351// resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
1352// its stopped target and commits creation under one d.mu critical section.
1353func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
1354 d.mu.Lock()
1355 stopped, ok := d.stopped[target.Name]
1356 var (
1357 transition attachmentTransitionResult
1358 switched bool
1359 )
1360 if ok && !stopped.purging && targetMatchesLifecycle(target, stopped.name, stopped.createdAt) {
1361 _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")
1362 }
1363 d.mu.Unlock()
1364 if !switched {
1365 d.invalidateRender(from, ac, true, "picker.go")
1366 return false
1367 }
1368 d.deferAttachmentTransitionCleanups(transition)
1369 d.firstPaintForTransition(transition.published)
1370 return true
1371}
1372
1373func (d *Daemon) killPickerTargetForAttachment(target picker.Target, token attachmentConnectionToken) error {
1374 if token.ac == nil {
1375 return d.killPickerTarget(target)
1376 }
1377 if target.RemoteKey != nil {
1378 return nil
1379 }
1380 if target.Stopped {
1381 token.effect.bindActionEnd(d, "picker-delete")
1382 token.effect.End()
1383 frozen := freezeAttachmentEffectGates(token.ac)
1384 defer frozen.unfreeze()
1385 if !d.sourceAttachmentTokenCurrentFrozen(token) {
1386 return nil
1387 }
1388 return d.killPickerTarget(target)
1389 }
1390 d.mu.Lock()
1391 targetSess := d.sessions[target.Session]
1392 d.mu.Unlock()
1393 if targetSess == nil {
1394 return nil
1395 }
1396 return d.killSessionForAttachment(targetSess, ports.ReasonSessionKilled, true, token, "picker-delete")
1397}
1398
1399func (d *Daemon) killPickerTarget(target picker.Target) error {
1400 if target.Stopped {
1401 if err := d.retryStoppedPurge(target.Name); err != nil {
1402 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
1403 return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)
1404 }
1405 return nil
1406 }
1407 d.mu.Lock()
1408 targetSess := d.sessions[target.Session]
1409 d.mu.Unlock()
1410 if targetSess != nil {
1411 return d.killSession(targetSess, ports.ReasonSessionKilled, true)
1412 }
1413 return nil
1414}
1415
1416func snapshotPickerPreview(tb *tab) picker.Preview {
1417 if tb == nil {
1418 return picker.Preview{}
1419 }
1420 tb.mu.Lock()
1421 layoutSnap := solveTabLayoutLocked(tb)
1422 if !layoutSnap.ok {
1423 p := tb.focusedPane()
1424 if p == nil {
1425 tb.mu.Unlock()
1426 return picker.Preview{}
1427 }
1428 p.mu.Lock()
1429 preview := pickerPreviewFromLockedPane(p)
1430 p.mu.Unlock()
1431 tb.mu.Unlock()
1432 return preview
1433 }
1434 state := capturedRenderState{layout: capturedTabLayout{
1435 area: layoutSnap.area, focus: layoutSnap.focus,
1436 placements: append([]layout.Placement(nil), layoutSnap.placements...),
1437 dividers: append([]layout.Divider(nil), layoutSnap.dividers...),
1438 fingerprint: layoutSnap.fingerprint, valid: true,
1439 }}
1440 for _, placement := range layoutSnap.placements {
1441 p := tb.panes[placement.ID]
1442 if p == nil {
1443 continue
1444 }
1445 visible := placement.Content
1446 if placement.Collapsed {
1447 visible = domain.Rect{}
1448 }
1449 p.mu.Lock()
1450 captured := capturePaneRenderStateLocked(p, visible)
1451 p.mu.Unlock()
1452 captured.placement = placement
1453 captured.focused = placement.ID == layoutSnap.focus
1454 state.panes = append(state.panes, captured)
1455 }
1456 tb.mu.Unlock()
1457 return pickerPreviewFromCapturedRender(state)
1458}
1459
1460func pickerPreviewFromCapturedRender(state capturedRenderState) picker.Preview {
1461 composed := composeFrame(state, composeCacheInput{}, composeCacheInput{}).frame
1462 if composed.Height < 2 {
1463 return pickerPreviewFromFrame(composed)
1464 }
1465 rows := make([][]renderer.Cell, composed.Height-2)
1466 for y := range rows {
1467 rows[y] = append([]renderer.Cell(nil), composed.Row(y+1)...)
1468 }
1469 return picker.Preview{Rows: rows, Width: composed.Width, Height: len(rows)}
1470}
1471
1472func pickerPreviewFromLockedPane(p *pane) picker.Preview {
1473 return pickerPreviewFromFrame(p.screen.Frame)
1474}
1475
1476func pickerPreviewFromFrame(frame renderer.Frame) picker.Preview {
1477 rows := make([][]renderer.Cell, frame.Height)
1478 for y := range rows {
1479 rows[y] = append([]renderer.Cell(nil), frame.Row(y)...)
1480 }
1481 return picker.Preview{Rows: rows, Width: frame.Width, Height: frame.Height}
1482}