1// Package daemon holds vev's server-side session multiplexer use case.
2package daemon
3
4import (
5 "context"
6 "sort"
7 "time"
8
9 "github.com/bnema/vev/internal/domain"
10 "github.com/bnema/vev/internal/protocol"
11 "github.com/bnema/vev/internal/usecase/picker"
12 "github.com/bnema/vev/internal/usecase/ui"
13)
14
15// The picker's floating geometry lives in the picker package: the presenting
16// client draws the same box, and this daemon sizes the preview it captures from
17// it, so both sides always agree.
18var pickerModal = picker.Modal
19
20const remotePickerPreviewDebounce = 80 * time.Millisecond
21
22// pickerLocalViews is the remote-free picker projection. The hybrid
23// projection interleaves foreign rows between the live and stopped rows, so
24// the two groups are returned separately and the stopped rows are returned
25// without a LOCAL section label. The assembler applies that label once it
26// knows whether any row precedes them.
27type pickerLocalViews struct {
28 current pickerSourceFilter
29 now time.Time
30 recentLive []pickerSessionView
31 groupedLive []pickerSessionView
32 recentStopped []pickerSessionView
33 groupedStopped []pickerSessionView
34}
35
36// localPickerViews is the prepared local-only picker projection. It captures
37// live and stopped local rows, ordering them as the hybrid projection does
38// when no foreign source is present.
39func (d *Daemon) localPickerViews(cur *session, ac *attachedClient) pickerLocalViews {
40 if cur != nil && ac != nil {
41 cur.repairAttachmentView(ac)
42 }
43 opts := viewOptions{tabDetails: true, focusedTitles: true, terminalTitle: d.currentTabsConfig().TerminalTitle}
44 inv := d.captureLocalSessionInventory(opts, true)
45 stopped := inv.visibleStopped()
46
47 // One snapshot per live session: sorting and view building read the same
48 // capture, so comparators cannot observe a concurrent touchMRU or
49 // renameSession mid-sort.
50 live := inv.live
51 var current pickerSourceFilter
52 for _, item := range live {
53 snap := item.view
54 if item.sess == cur {
55 if ac != nil {
56 view := ac.viewSnapshot()
57 if view.tabID != "" {
58 current = pickerSourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: view.tabID}
59 }
60 } else if len(snap.tabs) > 0 {
61 current = pickerSourceFilter{Session: snap.id, Incarnation: snap.incarnation, TabID: snap.tabs[snap.defaultTab].id}
62 }
63 }
64 }
65 sort.Slice(live, func(i, j int) bool {
66 if live[i].view.mruAt != live[j].view.mruAt {
67 return live[i].view.mruAt > live[j].view.mruAt
68 }
69 if live[i].view.name != live[j].view.name {
70 return live[i].view.name < live[j].view.name
71 }
72 return live[i].view.id < live[j].view.id
73 })
74 sort.Slice(stopped, func(i, j int) bool {
75 if stopped[i].lastUsedSeq != stopped[j].lastUsedSeq {
76 return stopped[i].lastUsedSeq > stopped[j].lastUsedSeq
77 }
78 return stopped[i].name < stopped[j].name
79 })
80
81 recentLive := make([]pickerSessionView, 0, len(live))
82 groupedLive := make([]pickerSessionView, 0, len(live))
83 for i, item := range live {
84 recentLive = append(recentLive, item.view.pickerView())
85 view := item.view.pickerView()
86 if i == 0 {
87 view.Section = "LOCAL"
88 }
89 groupedLive = append(groupedLive, view)
90 }
91 stoppedRows := stoppedPickerRows(stopped)
92 // The grouped projection labels its first stopped row in place, so the
93 // recent projection keeps its own copy.
94 recentStopped := append([]pickerSessionView(nil), stoppedRows...)
95 groupedStopped := stoppedRows
96 return pickerLocalViews{current: current, now: inv.now, recentLive: recentLive, groupedLive: groupedLive, recentStopped: recentStopped, groupedStopped: groupedStopped}
97}
98
99// stoppedPickerRows projects visible inactive records into picker rows. The
100// local and hybrid projections share this single row shape, and the callers
101// own whether a LOCAL section label is applied.
102func stoppedPickerRows(stopped []inactiveSession) []pickerSessionView {
103 rows := make([]pickerSessionView, 0, len(stopped))
104 for _, s := range stopped {
105 createdAt := s.createdAt
106 rows = append(rows, pickerSessionView{ID: domain.SessionID("stopped:" + s.name), Incarnation: s.incarnation, Name: s.name, TargetName: s.name, Stopped: true, ExpectedCreatedAt: &createdAt})
107 }
108 return rows
109}
110
111// localPickerViewProjections is the complete remote-free picker projection.
112// It appends the local stopped rows directly and applies the LOCAL section
113// label when no live row opened the group.
114func (d *Daemon) localPickerViewProjections(cur *session, ac *attachedClient) ([]pickerSessionView, []pickerSessionView, pickerSourceFilter) {
115 local := d.localPickerViews(cur, ac)
116 recent := make([]pickerSessionView, 0, len(local.recentLive)+len(local.recentStopped))
117 recent = append(recent, local.recentLive...)
118 recent = append(recent, local.recentStopped...)
119 grouped := make([]pickerSessionView, 0, len(local.groupedLive)+len(local.groupedStopped))
120 grouped = append(grouped, local.groupedLive...)
121 grouped = append(grouped, withLocalStoppedSection(local.groupedStopped, len(local.groupedLive) == 0)...)
122 return recent, grouped, local.current
123}
124
125func (d *Daemon) pickerViewProjections(cur *session, ac *attachedClient) ([]pickerSessionView, []pickerSessionView, pickerSourceFilter) {
126 return d.localPickerViewProjections(cur, ac)
127}
128
129// withLocalStoppedSection marks the first stopped row as the LOCAL section
130// when no preceding row already opened a local group.
131func withLocalStoppedSection(stopped []pickerSessionView, local bool) []pickerSessionView {
132 if local && len(stopped) > 0 {
133 stopped[0].Section = "LOCAL"
134 }
135 return stopped
136}
137
138func attentionSuffix(label string) string {
139 return label + " " + string(ui.AttentionGlyph)
140}
141
142func remotePickerPreviewSize(size domain.Size) (uint16, uint16) {
143 preview := picker.PreviewRect(size)
144 width, height := preview.Width, preview.Height
145 if width <= 0 || height <= 0 {
146 return 0, 0
147 }
148 return uint16(min(width, protocol.RemotePreviewMaxWidth)), uint16(min(height, protocol.RemotePreviewMaxHeight))
149}
150
151// pickerRouteTargetsEqual compares the exact route identity of two picker
152// targets, ignoring presentation fields such as UnavailableReason that
153// render around the route on every observation.
154func pickerRouteTargetsEqual(left, right picker.Target) bool {
155 if left.Session != right.Session || left.Incarnation != right.Incarnation || left.Name != right.Name ||
156 left.RemoteHost != right.RemoteHost || left.TabID != right.TabID || left.TabIndex != right.TabIndex || left.Stopped != right.Stopped ||
157 !remoteKeysEqual(left.RemoteKey, right.RemoteKey) ||
158 !remoteTargetsEqual(left.RemoteTarget, right.RemoteTarget) {
159 return false
160 }
161 if left.ExpectedCreatedAt == nil || right.ExpectedCreatedAt == nil {
162 return left.ExpectedCreatedAt == nil && right.ExpectedCreatedAt == nil
163 }
164 return *left.ExpectedCreatedAt == *right.ExpectedCreatedAt
165}
166
167func remoteKeysEqual(left, right *domain.RemoteSessionKey) bool {
168 if left == nil || right == nil {
169 return left == nil && right == nil
170 }
171 return *left == *right
172}
173
174func remoteTargetsEqual(left, right *domain.RemoteSessionTarget) bool {
175 if left == nil || right == nil {
176 return left == nil && right == nil
177 }
178 return *left == *right
179}
180
181func (d *Daemon) sessionByID(id domain.SessionID) *session {
182 d.mu.Lock()
183 defer d.mu.Unlock()
184 return d.sessions[id]
185}
186
187// sessionHandoffGuard carries explicit capabilities and source constraints for
188// one active-session handoff. Its zero value keeps daemon-owned transitions on
189// their established path.
190type sessionHandoffGuard struct {
191 expectedSource *tab
192 allowSamePeer bool
193}
194
195// switchActiveTargetForAttachment hands a frame-bound navigation request to the
196// centralized transition. The transition releases admission at its freeze seam
197// and revalidates the exact initiating token before changing target focus or
198// attachment ownership.
199func (d *Daemon) switchActiveTargetForAttachment(effect *attachmentEffect, target picker.Target) error {
200 return d.switchActiveTargetForAttachmentGuarded(effect, target, sessionHandoffGuard{}, "jump-attention")
201}
202
203func pickerTargetLifecycleFence(target picker.Target) *attachmentLifecycleFence {
204 fence := &attachmentLifecycleFence{}
205 if target.ExpectedCreatedAt != nil {
206 fence.name = target.Name
207 fence.createdAt = *target.ExpectedCreatedAt
208 fence.checkCreatedAt = true
209 }
210 if target.Incarnation != (domain.IncarnationID{}) {
211 fence.incarnation = target.Incarnation
212 fence.checkIncarnation = true
213 }
214 if target.TabID != "" {
215 fence.tabID = target.TabID
216 fence.tabIndex = target.TabIndex
217 fence.checkTab = true
218 }
219 if !fence.checkCreatedAt && !fence.checkIncarnation && !fence.checkTab {
220 return nil
221 }
222 return fence
223}
224
225func (d *Daemon) switchActiveTargetForAttachmentGuarded(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, action string) error {
226 if !effect.current() || effect.sess == nil || effect.ac == nil {
227 return nil
228 }
229 d.mu.Lock()
230 targetSess := d.sessions[target.Session]
231 d.mu.Unlock()
232 if targetSess == nil {
233 if !effect.current() {
234 return nil
235 }
236 d.invalidateRender(effect.sess, effect.ac, true, "picker.go")
237 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", nil)
238 }
239 if targetSess == effect.sess {
240 return nil
241 }
242
243 capability := effect.capability()
244 transition, err := d.transitionAttachment(attachmentTransitionRequest{
245 source: effect.sess, target: targetSess, next: effect.ac,
246
247 expectedTransport: effect.transport, sourceCapability: &capability, sourceEffect: effect, action: action,
248 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
249 activateTargetTab: true, targetTabIndex: target.TabIndex, ready: true,
250 })
251 if err != nil {
252 // Losing the exact source role is a benign stale action, not a notice for
253 // whichever attachment replaced the initiator.
254 if !capability.current() {
255 return nil
256 }
257 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
258 }
259 d.touchMRU(targetSess)
260 d.deferAttachmentTransitionCleanups(transition)
261 d.firstPaintForTransition(transition.published)
262 return nil
263}
264
265// switchToTarget resolves named lifecycle targets and commits their transition
266// while d.mu is held. A named palette result is allowed to cross an
267// active/stopped transition, but it never follows a same-name replacement.
268func (d *Daemon) switchToTarget(from *session, ac *attachedClient, target picker.Target) error {
269 return d.switchToTargetGuarded(from, ac, target, sessionHandoffGuard{})
270}
271
272// switchToTargetForAttachment is the sole client-originated navigation entry. The
273// intent captures the exact initiating capability before admission is released;
274// every active, stopped, and same-session target then uses transitionAttachment
275// for frozen, atomic source-token preflight.
276func (d *Daemon) switchToTargetForAttachment(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, action string) error {
277 if !effect.current() || effect.sess == nil || effect.ac == nil {
278 return nil
279 }
280 if target.RemoteTarget != nil || target.RemoteKey != nil {
281 // Other daemons are client routes: the daemon never
282 // hands out a remote destination.
283 return errAttachmentTransition
284 }
285 return d.sendLocalAttachTargetForAttachment(effect, target, guard, action)
286}
287
288// sendLocalAttachTargetForAttachment offers an endpoint-empty, exact local
289// target on the current authenticated connection. A current client confirms it
290// with SamePeerSwitchRequest; an interrupted or older client retains the
291// existing close-and-redial fallback without any daemon-origin inference.
292func (d *Daemon) sendLocalAttachTargetForAttachment(effect *attachmentEffect, target picker.Target, guard sessionHandoffGuard, action string) error {
293 d.mu.Lock()
294 var targetSess *session
295 var sessionName string
296 var exactTarget *protocol.ExactSessionTarget
297 var matches bool
298 if target.Name != "" {
299 var stopped inactiveSession
300 var stoppedTarget bool
301 targetSess, stopped, stoppedTarget, matches = d.resolveNamedLifecycleTargetLocked(target)
302 if stoppedTarget && matches {
303 sessionName = stopped.name
304 exactTarget = &protocol.ExactSessionTarget{LifecycleID: stopped.incarnation, SessionName: sessionName}
305 }
306 } else {
307 targetSess = d.sessions[target.Session]
308 matches = targetSess != nil
309 }
310 if matches && targetSess != nil {
311 targetSess.mu.Lock()
312 sessionName = targetSess.name
313 exactTarget = &protocol.ExactSessionTarget{LifecycleID: targetSess.incarnation, SessionName: sessionName}
314 targetSess.mu.Unlock()
315 }
316 d.mu.Unlock()
317 if !matches || !effect.current() {
318 return errAttachmentTransition
319 }
320 if targetSess != nil && target.TabID != "" {
321 // Ordinary local tab navigation keeps the attachment.
322 return d.switchToTargetGuardedForAttachment(effect.sess, effect.ac, target, guard, effect, action)
323 }
324
325 // Ordinary live session rows can use the client-confirmed same-peer
326 // path. Stopped targets require a handoff.
327 samePeerEligible := guard.allowSamePeer && targetSess != nil && target.TabIndex <= 0
328 if exactTarget == nil {
329 return errAttachmentTransition
330 }
331 // A close-and-dial handoff leaves this daemon's Kitty namespace. A
332 // same-peer transition keeps the attachment and its namespace; the target
333 // scene diff deletes and replaces the source session's placements.
334 if samePeerEligible {
335 if err := offerSamePeerAttachTarget(effect, *exactTarget); err != nil {
336 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't offer local session switch", err)
337 }
338 return nil
339 }
340 handoff := protocol.AttachTarget{
341 Session: sessionName,
342 Intent: protocol.IntentAttach,
343 ExactTarget: exactTarget,
344 PreferredTabID: target.TabID,
345 EnvironmentPolicy: protocol.EnvironmentPolicyDaemonOwned,
346 }
347 if protocol.ValidateAttachTarget(handoff) != nil {
348 return errAttachmentTransition
349 }
350 if err := d.cleanupAttachmentOutput(effect.ac); err != nil {
351 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't clean up attachment output before local handoff", err)
352 }
353 if err := effect.sendControl(handoff); err != nil {
354 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't offer local session switch", err)
355 }
356 // Stopped and explicit non-default index targets retain the close-and-dial
357 // handoff instead of losing their target-specific transition semantics.
358 // Keep the source link open until the client receives the ordered cleanup
359 // and handoff frames, then let the client's close drive ordinary parking.
360 effect.bindActionEnd(d, "detach")
361 effect.End()
362 return nil
363}
364
365// switchToTargetGuarded is retained for daemon-internal and headless callers.
366func (d *Daemon) switchToTargetGuarded(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard) error {
367 return d.switchToTargetGuardedForAttachment(from, ac, target, guard, nil, "")
368}
369
370func (d *Daemon) switchToTargetGuardedForAttachment(from *session, ac *attachedClient, target picker.Target, guard sessionHandoffGuard, sourceEffect *attachmentEffect, action string) error {
371 var sourceCapability *attachmentCapability
372 if sourceEffect != nil {
373 capability := sourceEffect.capability()
374 sourceCapability = &capability
375 }
376 if target.Name != "" {
377 ctx := d.serveCtx
378 if ctx == nil {
379 ctx = context.Background()
380 }
381 if err := d.waitForTargetRestore(ctx, target.Name); err != nil {
382 if sourceCapability != nil && !sourceCapability.current() {
383 return errAttachmentTransition
384 }
385 d.invalidateRender(from, ac, true, "picker.go")
386 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", err)
387 }
388 }
389
390 d.mu.Lock()
391 var (
392 targetSess *session
393 transition attachmentTransitionResult
394 switched bool
395 cause error
396 )
397 if target.Name != "" {
398 active, stopped, isStopped, ok := d.resolveNamedLifecycleTargetLocked(target)
399 if ok {
400 if isStopped {
401 targetSess, transition, switched, cause = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, sourceEffect, guard, action)
402 } else {
403 // The snapshot ID may describe the stopped representation. The
404 // locked name/lifecycle resolver chose this active incarnation.
405 resolvedTarget := target
406 resolvedTarget.Session = active.id
407 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, resolvedTarget, guard, sourceEffect, action)
408 }
409 }
410 } else if active := d.sessions[target.Session]; active != nil {
411 targetSess, transition, switched = d.switchToActiveTargetLocked(from, ac, active, target, guard, sourceEffect, action)
412 }
413 d.mu.Unlock()
414
415 if !switched {
416 if sourceCapability != nil && !sourceCapability.current() {
417 return errAttachmentTransition
418 }
419 if guard.expectedSource != nil {
420 return errNoNeighbor
421 }
422 d.invalidateRender(from, ac, true, "picker.go")
423 return domain.UserErr(domain.NoticeSessionUnavailable, "couldn't switch to that session", cause)
424 }
425 d.deferAttachmentTransitionCleanups(transition)
426 if targetSess == from {
427 if sourceEffect != nil {
428 fresh, admitted := ac.beginAttachmentEffect(transition.published)
429 if admitted {
430 d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, transition.published.lease)
431 d.invalidateRender(from, ac, true, "picker.go")
432 fresh.End()
433 }
434 } else {
435 d.activateTabAfterResizeForLease(from, from.tabForAttachment(ac), false, ac, nil)
436 d.invalidateRender(from, ac, true, "picker.go")
437 }
438 } else {
439 d.firstPaintForTransition(transition.published)
440 }
441 return nil
442}
443
444// resolveNamedLifecycleTargetLocked chooses exactly one current representation
445// of a named lifecycle. Caller holds d.mu. Matching the name and lifecycle
446// identity here closes the lookup-to-handoff window for palette targets.
447func (d *Daemon) resolveNamedLifecycleTargetLocked(target picker.Target) (*session, inactiveSession, bool, bool) {
448 if target.Name == "" {
449 return nil, inactiveSession{}, false, false
450 }
451 if active := d.findByNameLocked(target.Name); active != nil {
452 active.mu.Lock()
453 matches := targetMatchesLifecycle(target, active.name, active.createdAt, active.incarnation)
454 active.mu.Unlock()
455 return active, inactiveSession{}, false, matches
456 }
457 stopped, ok := d.inactive[target.Name]
458 if !ok || !stopped.canResume() || !targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
459 return nil, inactiveSession{}, false, false
460 }
461 return nil, stopped, true, true
462}
463
464// switchToActiveTargetLocked commits an active target handoff through the
465// centralized attachment transition. Caller holds d.mu.
466func (d *Daemon) switchToActiveTargetLocked(from *session, ac *attachedClient, targetSess *session, target picker.Target, guard sessionHandoffGuard, sourceEffect *attachmentEffect, action string) (*session, attachmentTransitionResult, bool) {
467 var sourceCapability *attachmentCapability
468 if sourceEffect != nil {
469 capability := sourceEffect.capability()
470 sourceCapability = &capability
471 }
472 if d.sessions[target.Session] != targetSess {
473 return nil, attachmentTransitionResult{}, false
474 }
475 if targetSess == from {
476 if sourceEffect == nil {
477 targetSess.mu.Lock()
478 defer targetSess.mu.Unlock()
479 _, attached := targetSess.attachments[ac]
480 if !attached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) || target.TabIndex < 0 || target.TabIndex >= len(targetSess.tabs) {
481 return nil, attachmentTransitionResult{}, false
482 }
483 if !targetSess.activateAttachmentViewLocked(ac, target.TabIndex) {
484 return nil, attachmentTransitionResult{}, false
485 }
486 return targetSess, attachmentTransitionResult{}, true
487 }
488 d.mu.Unlock()
489 transition, err := d.transitionAttachment(attachmentTransitionRequest{
490 source: from, target: targetSess, next: ac,
491
492 expectedTransport: sourceCapability.transport,
493 sourceCapability: sourceCapability,
494 sourceEffect: sourceEffect,
495 action: action,
496 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
497 activateTargetTab: true,
498 targetTabIndex: target.TabIndex,
499 preserveAttachment: true,
500 })
501 d.mu.Lock()
502 return targetSess, transition, err == nil
503 }
504
505 unlock := lockAttachmentSessions(from, targetSess)
506 _, sourceAttached := from.attachments[ac]
507 if !sourceAttached || !targetMatchesLifecycle(target, targetSess.name, targetSess.createdAt, targetSess.incarnation) {
508 unlock()
509 return nil, attachmentTransitionResult{}, false
510 }
511 if guard.expectedSource != nil {
512 view := ac.viewSnapshot()
513 if domain.TabStableID(guard.expectedSource.stableID) != view.tabID {
514 unlock()
515 return nil, attachmentTransitionResult{}, false
516 }
517 guard.expectedSource.mu.Lock()
518 sourceHasFloating := guard.expectedSource.floating.state == floatingVisible
519 guard.expectedSource.mu.Unlock()
520 if sourceHasFloating {
521 unlock()
522 return nil, attachmentTransitionResult{}, false
523 }
524 }
525 unlock()
526
527 // Release d.mu before the gate freeze/drain. transitionAttachment reacquires
528 // d.mu and revalidates both registered lifecycles for publication.
529 d.mu.Unlock()
530 expectedTransport := ac.transportSnapshot()
531 if sourceCapability != nil {
532 expectedTransport = sourceCapability.transport
533 }
534 transition, err := d.transitionAttachment(attachmentTransitionRequest{
535 source: from,
536 target: targetSess,
537 next: ac,
538
539 expectedTransport: expectedTransport,
540 sourceCapability: sourceCapability,
541 sourceEffect: sourceEffect,
542 action: action,
543 expectedTargetLifecycle: pickerTargetLifecycleFence(target),
544 expectedSourceTab: guard.expectedSource,
545 activateTargetTab: target.TabIndex >= 0,
546 targetTabIndex: target.TabIndex,
547 ready: true,
548 })
549 d.mu.Lock()
550 if err != nil {
551 return nil, attachmentTransitionResult{}, false
552 }
553 d.touchMRU(targetSess)
554 return targetSess, transition, true
555}
556
557// resumeStoppedAndSwitchLocked creates the stopped representation and commits
558// the handoff while d.mu is held. Creation failure leaves the source client
559// and stopped record untouched.
560func (d *Daemon) resumeStoppedAndSwitchLocked(from *session, ac *attachedClient, target picker.Target, stopped inactiveSession, sourceEffect *attachmentEffect, guard sessionHandoffGuard, action string) (*session, attachmentTransitionResult, bool, error) {
561 if stopped.broken() {
562 return nil, attachmentTransitionResult{}, false, &protoErr{protocol.ErrInternal, "session durable state is broken: " + target.Name}
563 }
564 if !stopped.canResume() {
565 return nil, attachmentTransitionResult{}, false, errAttachmentTransition
566 }
567
568 if sourceEffect != nil {
569 sourceCapability := sourceEffect.capability()
570 var targetSess *session
571 d.mu.Unlock()
572 transition, err := d.transitionAttachment(attachmentTransitionRequest{
573 source: from, next: ac,
574 expectedTransport: sourceCapability.transport, sourceCapability: &sourceCapability, sourceEffect: sourceEffect, action: action,
575 expectedSourceTab: guard.expectedSource, ready: true,
576 createTargetLocked: func() (*session, error) {
577 if d.purgeAdmissionClosedLocked() {
578 return nil, errPurgeAdmissionClosed
579 }
580 current, ok := d.inactive[target.Name]
581 if !ok || !current.canResume() || !current.sameLifecycle(stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
582 return nil, errAttachmentTransition
583 }
584 from.mu.Lock()
585 env, provisional := from.environmentSeedLocked()
586 from.mu.Unlock()
587 cwd := d.dirOrHome(current.cwd)
588 created, createErr := d.resumeInactiveSessionLocked(target.Name, cwd, ac.geometrySnapshot(), env, current, current.tabNames)
589 if createErr == nil && provisional {
590 markEnvironmentProvisional(created)
591 }
592 targetSess = created
593 return created, createErr
594 },
595 })
596 d.mu.Lock()
597 if err != nil {
598 if targetSess != nil {
599 d.mu.Unlock()
600 _ = d.killSession(targetSess, protocol.ReasonSessionKilled, true)
601 d.mu.Lock()
602 }
603 return targetSess, attachmentTransitionResult{}, false, err
604 }
605 d.touchMRU(targetSess)
606 return targetSess, transition, true, nil
607 }
608
609 from.mu.Lock()
610 _, attached := from.attachments[ac]
611 if !attached {
612 from.mu.Unlock()
613 return nil, attachmentTransitionResult{}, false, nil
614 }
615 env, provisional := from.environmentSeedLocked()
616 from.mu.Unlock()
617 if d.purgeAdmissionClosedLocked() {
618 return nil, attachmentTransitionResult{}, false, errPurgeAdmissionClosed
619 }
620 current, ok := d.inactive[target.Name]
621 if !ok || !current.canResume() || !current.sameLifecycle(stopped) || !targetMatchesLifecycle(target, current.name, current.createdAt, current.incarnation) {
622 return nil, attachmentTransitionResult{}, false, errAttachmentTransition
623 }
624 cwd := d.dirOrHome(current.cwd)
625 targetSess, err := d.resumeInactiveSessionLocked(target.Name, cwd, ac.geometrySnapshot(), env, current, current.tabNames)
626 if err != nil {
627 d.log.Warn("resuming stopped session failed", "err", err, "session", target.Name)
628 return nil, attachmentTransitionResult{}, false, err
629 }
630 if provisional {
631 markEnvironmentProvisional(targetSess)
632 }
633
634 d.mu.Unlock()
635 transition, err := d.transitionAttachment(attachmentTransitionRequest{
636 source: from, target: targetSess, next: ac,
637
638 expectedTransport: ac.transportSnapshot(), ready: true,
639 })
640 d.mu.Lock()
641 if err != nil {
642 return targetSess, attachmentTransitionResult{}, false, err
643 }
644 d.touchMRU(targetSess)
645 return targetSess, transition, true, nil
646}
647
648func targetMatchesLifecycle(target picker.Target, name string, createdAt int64, incarnation domain.IncarnationID) bool {
649 if target.Incarnation != (domain.IncarnationID{}) && target.Incarnation != incarnation {
650 return false
651 }
652 return target.ExpectedCreatedAt == nil || (target.Name == name && *target.ExpectedCreatedAt == createdAt)
653}
654
655// stealClientForTarget is retained for direct-ID callers and tests. Named
656// targets must use switchToTarget so resolution and commit share d.mu.
657func (d *Daemon) stealClientForTarget(from *session, ac *attachedClient, targetSess *session, target picker.Target) {
658 d.mu.Lock()
659 _, transition, switched := d.switchToActiveTargetLocked(from, ac, targetSess, target, sessionHandoffGuard{}, nil, "")
660 d.mu.Unlock()
661 if !switched {
662 return
663 }
664 d.deferAttachmentTransitionCleanups(transition)
665}
666
667// resumeStoppedAndSwitch is retained for direct callers and tests. It resolves
668// its stopped target and commits creation under one d.mu critical section.
669func (d *Daemon) resumeStoppedAndSwitch(from *session, ac *attachedClient, target picker.Target) bool {
670 d.mu.Lock()
671 stopped, ok := d.inactive[target.Name]
672 var (
673 transition attachmentTransitionResult
674 switched bool
675 )
676 if ok && stopped.canResume() && targetMatchesLifecycle(target, stopped.name, stopped.createdAt, stopped.incarnation) {
677 _, transition, switched, _ = d.resumeStoppedAndSwitchLocked(from, ac, target, stopped, nil, sessionHandoffGuard{}, "")
678 }
679 d.mu.Unlock()
680 if !switched {
681 d.invalidateRender(from, ac, true, "picker.go")
682 return false
683 }
684 d.deferAttachmentTransitionCleanups(transition)
685 d.firstPaintForTransition(transition.published)
686 return true
687}
688
689func (d *Daemon) killPickerTargetForAttachment(target picker.Target, effect *attachmentEffect) error {
690 if effect == nil || effect.ac == nil {
691 return d.killPickerTarget(target)
692 }
693 if target.RemoteKey != nil {
694 return nil
695 }
696 capability := effect.capability()
697 if target.Stopped {
698 effect.bindActionEnd(d, "picker-delete")
699 effect.End()
700 frozen := freezeAttachmentEffectGates(effect.ac)
701 defer frozen.unfreeze()
702 if !d.sourceAttachmentCapabilityCurrentFrozen(capability) {
703 return nil
704 }
705 return d.killPickerTarget(target)
706 }
707 d.mu.Lock()
708 targetSess := d.sessions[target.Session]
709 d.mu.Unlock()
710 if targetSess == nil {
711 return nil
712 }
713 return d.killSessionForAttachment(targetSess, protocol.ReasonSessionKilled, true, effect, "picker-delete")
714}
715
716func (d *Daemon) killPickerTarget(target picker.Target) error {
717 if target.Stopped {
718 if err := d.retryStoppedPurgeExact(target.Name, target.Incarnation, target.ExpectedCreatedAt); err != nil {
719 d.log.Warn("deleting persisted stopped session failed", "err", err, "session", target.Name)
720 return domain.UserErr(domain.NoticePersistDelete, "couldn't delete stopped session", err)
721 }
722 return nil
723 }
724 d.mu.Lock()
725 targetSess := d.sessions[target.Session]
726 d.mu.Unlock()
727 if targetSess != nil {
728 return d.killSession(targetSess, protocol.ReasonSessionKilled, true)
729 }
730 return nil
731}