1package daemon
2
3import (
4 "crypto/rand"
5 "encoding/binary"
6 "errors"
7
8 "github.com/bnema/vev/internal/domain"
9 "github.com/bnema/vev/internal/ports"
10)
11
12var errResumeTokenLifecycleRace = errors.New("resume token lifecycle race")
13
14// resumeTokenSnapshot reads the credential under the same daemon lock used by
15// its lifecycle writers. Callers must not hold d.mu.
16func (d *Daemon) resumeTokenSnapshot(ac *attachedClient) uint64 {
17 if ac == nil {
18 return 0
19 }
20 d.mu.Lock()
21 defer d.mu.Unlock()
22 return ac.resumeToken
23}
24
25func newResumeToken() uint64 {
26 var b [8]byte
27 if _, err := rand.Read(b[:]); err != nil {
28 panic("crypto/rand failed generating resume token: " + err.Error())
29 }
30 return binary.BigEndian.Uint64(b[:])
31}
32
33func (d *Daemon) nextResumeTokenLocked() uint64 {
34 for {
35 tok := newResumeToken()
36 if tok == 0 {
37 continue
38 }
39 if _, exists := d.parked[tok]; !exists {
40 if _, parking := d.parking[tok]; !parking {
41 return tok
42 }
43 }
44 }
45}
46
47// markParkingInFlight publishes an observable parking lifecycle so IntentResume
48// can wait out the detach→park gap. Callers must publish this before clearing
49// the live seat so resume never observes both registries empty for a still-valid
50// credential. Under d.mu then sess.mu it advertises only when the daemon still
51// registers the exact session and ac remains a registered attachment; otherwise
52// it returns 0 and never creates a marker after terminal
53// cleanup. Returns the resume token, or 0 when parking cannot be advertised.
54func (d *Daemon) markParkingInFlight(sess *session, ac *attachedClient) uint64 {
55 if sess == nil || ac == nil || !ac.resumeCapable {
56 return 0
57 }
58 d.mu.Lock()
59 defer d.mu.Unlock()
60 if d.closing || d.sessions[sess.id] != sess {
61 return 0
62 }
63 sess.mu.Lock()
64 registered := attachmentRegisteredLocked(sess, ac)
65 sess.mu.Unlock()
66 if !registered {
67 return 0
68 }
69 if ac.resumeToken == 0 {
70 ac.resumeToken = d.nextResumeTokenLocked()
71 }
72 d.ensureParkingInFlightLocked(sess, ac)
73 d.log.Info("parking in flight", "session", sess.nameSnapshot())
74 return ac.resumeToken
75}
76
77// ensureParkingInFlightLocked records or refreshes the in-flight marker for ac.
78// Caller holds d.mu and has verified the daemon is not closing.
79func (d *Daemon) ensureParkingInFlightLocked(sess *session, ac *attachedClient) {
80 token := ac.resumeToken
81 if token == 0 {
82 return
83 }
84 if existing := d.parking[token]; existing != nil {
85 if existing.ac == ac {
86 existing.sess = sess
87 return
88 }
89 delete(d.parking, token)
90 existing.closeDone()
91 }
92 d.parking[token] = &parkingAttachment{
93 sess: sess,
94 ac: ac,
95 done: make(chan struct{}),
96 }
97}
98
99func (d *Daemon) clearParkingInFlight(token uint64, ac *attachedClient) {
100 if token == 0 {
101 return
102 }
103 d.mu.Lock()
104 d.clearParkingInFlightLocked(token, ac)
105 d.mu.Unlock()
106}
107
108// clearParkingInFlightIfAbandoned drops a pre-detach parking marker when this
109// teardown lost the seat while still the live owner (stale transport fence).
110// The live owner clears its own marker; if another path already detached or
111// unrouted, that owning teardown is responsible for park publication/cleanup.
112func (d *Daemon) clearParkingInFlightIfAbandoned(sess *session, ac *attachedClient, token uint64) {
113 if token == 0 || ac == nil {
114 return
115 }
116 d.mu.Lock()
117 defer d.mu.Unlock()
118 if d.parked[token] != nil {
119 return
120 }
121 stillOwner := false
122 if sess != nil {
123 sess.mu.Lock()
124 stillOwner = attachmentRegisteredLocked(sess, ac)
125 sess.mu.Unlock()
126 }
127 if !stillOwner {
128 return
129 }
130 d.clearParkingInFlightLocked(token, ac)
131}
132
133func (d *Daemon) clearParkingInFlightLocked(token uint64, ac *attachedClient) {
134 pending := d.parking[token]
135 if pending == nil {
136 return
137 }
138 if ac != nil && pending.ac != ac {
139 return
140 }
141 delete(d.parking, token)
142 pending.closeDone()
143}
144
145func (d *Daemon) purgeAllParkingLocked() {
146 for token, pending := range d.parking {
147 delete(d.parking, token)
148 pending.closeDone()
149 }
150}
151
152// purgeParkingForSessionLocked removes and closes every in-flight parking
153// marker for sess so same-token waiters fail closed instead of hanging when
154// the session is removed before park publication. Caller holds d.mu.
155func (d *Daemon) purgeParkingForSessionLocked(sess *session) {
156 for token, pending := range d.parking {
157 if pending.sess == sess {
158 delete(d.parking, token)
159 pending.closeDone()
160 }
161 }
162}
163
164// waitParkingInFlight waits for an identified same-client parking publication
165// to finish. Returns true when a matching in-flight entry was observed.
166// Unknown and mismatched credentials return false without waiting.
167func (d *Daemon) waitParkingInFlight(h ports.Hello) bool {
168 if h.ResumeToken == 0 {
169 return false
170 }
171 d.mu.Lock()
172 pending := d.parking[h.ResumeToken]
173 if pending == nil {
174 d.mu.Unlock()
175 return false
176 }
177 if pending.ac.clientID != h.ClientID {
178 d.mu.Unlock()
179 return false
180 }
181 if h.Name != "" && pending.sess != nil && pending.sess.nameSnapshot() != h.Name {
182 d.mu.Unlock()
183 return false
184 }
185 done := pending.done
186 d.mu.Unlock()
187 d.log.Info("resume waiting for in-flight parking", "session", h.Name)
188 if d.afterParkingWaitArmed != nil {
189 d.afterParkingWaitArmed()
190 }
191 <-done
192 return true
193}
194
195func (d *Daemon) prepareParkAttachment(sess *session, ac *attachedClient) bool {
196 if sess == nil || ac == nil || !ac.resumeCapable {
197 return false
198 }
199 d.mu.Lock()
200 defer d.mu.Unlock()
201 if d.closing {
202 return false
203 }
204 if ac.resumeToken == 0 {
205 ac.resumeToken = d.nextResumeTokenLocked()
206 }
207 // Do not recreate parking markers here: post-detach publication races
208 // terminal session removal and can strand IntentResume waiters. Callers
209 // that need the detach→park gap advertised must markParkingInFlight while
210 // still the exact live owner. Direct parkAttachment still parks below.
211 d.log.Info("parking client prepared", "session", sess.nameSnapshot())
212 return true
213}
214
215func (d *Daemon) parkAttachment(sess *session, ac *attachedClient) bool {
216 if !d.prepareParkAttachment(sess, ac) {
217 return false
218 }
219 // A parked attachment has no session owner. Release pane snapshots before
220 // publishing it in the daemon registry so headless pane/session teardown
221 // cannot leave closed panes retained through its capture cache. This must
222 // remain outside d.mu: sendMu is ordered before the daemon lock.
223 ac.clearCaptureFrames()
224 var pickerGeneration uint64
225 if ac.overlays != nil {
226 ac.overlays.pickerMu.Lock()
227 pickerGeneration = ac.overlays.pickerGeneration
228 ac.overlays.pickerMu.Unlock()
229 }
230 d.mu.Lock()
231 if d.closing || d.sessions[sess.id] != sess {
232 d.clearParkingInFlightLocked(ac.resumeToken, ac)
233 d.mu.Unlock()
234 return false
235 }
236 token := ac.resumeToken
237 var (
238 oldRetirement *parkedAttachmentRetirement
239 oldSame *parkedAttachment
240 )
241 if old := d.parked[token]; old != nil {
242 if old.ac == ac {
243 oldSame = old
244 } else {
245 retirement := d.retireParkedAttachmentLocked(token, old)
246 oldRetirement = &retirement
247 }
248 }
249 grace := d.resumeParkGrace
250 timer := d.clock.NewTimer(grace)
251 parked := &parkedAttachment{sess: sess, ac: ac, pickerGeneration: pickerGeneration, timer: timer, done: make(chan struct{})}
252 ac.parked = true
253 d.parked[token] = parked
254 d.clearParkingInFlightLocked(token, ac)
255 d.mu.Unlock()
256 if oldRetirement != nil {
257 d.finishParkedAttachmentRetirements([]parkedAttachmentRetirement{*oldRetirement})
258 }
259 if oldSame != nil {
260 if oldSame.timer != nil {
261 oldSame.timer.Stop()
262 }
263 oldSame.closeDone()
264 }
265 d.log.Info("client parked for resume", "session", sess.nameSnapshot(), "grace", grace)
266 d.watchParkedTimer(token, parked)
267 return true
268}
269
270func (d *Daemon) watchParkedTimer(token uint64, parked *parkedAttachment) {
271 if parked == nil || parked.timer == nil {
272 return
273 }
274 timer := parked.timer
275 go func() {
276 select {
277 case <-timer.C():
278 d.expireParked(token, parked)
279 case <-parked.done:
280 }
281 }()
282}
283
284func (d *Daemon) expireParked(token uint64, parked *parkedAttachment) {
285 d.mu.Lock()
286 if d.parked[token] == parked {
287 if parked.claimed {
288 d.mu.Unlock()
289 return
290 }
291 d.removeParkedLocked(token, parked)
292 d.mu.Unlock()
293 d.closePickerIfCurrent(parked.ac, nil, parked.pickerGeneration)
294 d.log.Warn("parked client expired", "session", parked.sess.nameSnapshot())
295 return
296 }
297 d.mu.Unlock()
298}
299
300// removeParkedLocked invalidates one parked attachment. Caller holds d.mu and
301// has verified d.parked[token] still points at parked when that matters.
302func (d *Daemon) removeParkedLocked(token uint64, parked *parkedAttachment) {
303 delete(d.parked, token)
304 parked.ac.resumeToken = 0
305 parked.ac.parked = false
306 if parked.timer != nil {
307 parked.timer.Stop()
308 }
309 parked.closeDone()
310}
311
312func (p *parkedAttachment) closeDone() {
313 if p.done == nil {
314 return
315 }
316 p.doneOnce.Do(func() { close(p.done) })
317}
318
319// commitResumeClaim consumes the old credential only after Welcome has been
320// written successfully. The claim itself is serialized by d.mu, so a racing
321// resume cannot acquire the same parked attachment.
322func (d *Daemon) commitResumeClaim(ac *attachedClient) bool {
323 if d == nil || ac == nil {
324 return false
325 }
326 d.mu.Lock()
327 defer d.mu.Unlock()
328 token := ac.resumeClaimToken
329 parked := d.parked[token]
330 if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
331 return token == 0
332 }
333 delete(d.parked, token)
334 parked.claimed = false
335 if parked.timer != nil {
336 parked.timer.Stop()
337 }
338 parked.closeDone()
339 ac.resumeClaimToken = 0
340 return true
341}
342
343// abortResumeClaim restores a parked credential when the transport could not
344// complete the pre-claim Welcome handshake. It invalidates the claimed link
345// generation before making the credential available to another winner.
346func (d *Daemon) abortResumeClaim(ac *attachedClient) bool {
347 if d == nil || ac == nil {
348 return false
349 }
350 d.mu.Lock()
351 token := ac.resumeClaimToken
352 parked := d.parked[token]
353 if token == 0 || parked == nil || parked.ac != ac || !parked.claimed {
354 d.mu.Unlock()
355 return false
356 }
357 parked.claimed = false
358 ac.resumeClaimToken = 0
359 ac.resumeToken = token
360 ac.parked = true
361 ac.connectionGeneration.Add(1)
362 sess := parked.sess
363 if sess != nil {
364 sess.mu.Lock()
365 sess.unregisterAttachmentLocked(ac)
366 sess.mu.Unlock()
367 }
368 // Recreate the parked entry and its watcher instead of restoring a timer
369 // that may already have fired while the claim held the old entry. This also
370 // makes a failed handshake's credential expiry deterministic under fake clocks.
371 if parked.timer != nil {
372 parked.timer.Stop()
373 }
374 parked.closeDone()
375 rearmed := &parkedAttachment{
376 sess: parked.sess,
377 ac: ac,
378 pickerGeneration: parked.pickerGeneration,
379 timer: d.clock.NewTimer(d.resumeParkGrace),
380 done: make(chan struct{}),
381 }
382 d.parked[token] = rearmed
383 captured := ac.transportSnapshot().transport
384 ac.setSession(nil)
385 d.mu.Unlock()
386 if sess != nil {
387 d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
388 }
389 d.watchParkedTimer(token, rearmed)
390 if captured != nil {
391 _ = ac.closeCapturedTransport(ac.revokeTransport(captured))
392 }
393 return true
394}
395
396type parkedAttachmentRetirement struct {
397 parked *parkedAttachment
398 pickerGeneration uint64
399 transport transportSnapshot
400}
401
402func (d *Daemon) retireParkedAttachmentLocked(token uint64, parked *parkedAttachment) parkedAttachmentRetirement {
403 delete(d.parked, token)
404 parked.ac.resumeToken = 0
405 parked.ac.parked = false
406 parked.ac.connectionGeneration.Add(1)
407 parked.ac.setSession(nil)
408 return parkedAttachmentRetirement{
409 parked: parked,
410 pickerGeneration: parked.pickerGeneration,
411 transport: parked.ac.transportSnapshot(),
412 }
413}
414
415// purgeParkedForSessionLocked invalidates every parked token for sess and
416// returns the external resources that must be retired after releasing d.mu.
417// It is reserved for terminal session kill and daemon shutdown.
418func (d *Daemon) purgeParkedForSessionLocked(sess *session) []parkedAttachmentRetirement {
419 var retirements []parkedAttachmentRetirement
420 for token, parked := range d.parked {
421 if parked.sess == sess {
422 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
423 }
424 }
425 return retirements
426}
427
428func (d *Daemon) purgeAllParkedLocked() []parkedAttachmentRetirement {
429 retirements := make([]parkedAttachmentRetirement, 0, len(d.parked))
430 for token, parked := range d.parked {
431 retirements = append(retirements, d.retireParkedAttachmentLocked(token, parked))
432 }
433 return retirements
434}
435
436// finishParkedAttachmentRetirements stops timers and retires picker ownership
437// and transports without holding daemon, session, coordinator, or pane locks.
438func (d *Daemon) finishParkedAttachmentRetirements(retirements []parkedAttachmentRetirement) {
439 for _, retirement := range retirements {
440 if retirement.parked.timer != nil {
441 retirement.parked.timer.Stop()
442 }
443 retirement.parked.closeDone()
444 ac := retirement.parked.ac
445 d.closePickerIfCurrent(ac, nil, retirement.pickerGeneration)
446 _ = ac.closeCapturedTransport(ac.revokeTransport(retirement.transport.transport))
447 }
448}
449
450// resumeLiveAttachment recovers a nonzero resume credential that still belongs
451// to the named session's active attachment because transport teardown has not
452// parked it yet. Only the exact live owner (same session, token, and client ID)
453// is accepted; arbitrary unknown tokens stay fail-closed.
454func (d *Daemon) resumeLiveAttachment(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) {
455 d.mu.Lock()
456 if d.closing {
457 d.mu.Unlock()
458 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
459 }
460 // Teardown may have parked the credential already.
461 if d.parked[h.ResumeToken] != nil {
462 d.mu.Unlock()
463 return d.resumeParked(h, tr, sz)
464 }
465 sess := d.findByNameLocked(h.Name)
466 var (
467 ac *attachedClient
468 oldSnap transportSnapshot
469 credentialMatch bool
470 clientMismatch bool
471 )
472 if sess != nil {
473 sess.mu.Lock()
474 for candidate := range sess.attachments {
475 if !candidate.resumeCapable || candidate.resumeToken != h.ResumeToken {
476 continue
477 }
478 ac = candidate
479 if ac.clientID != h.ClientID {
480 clientMismatch = true
481 } else {
482 oldSnap = ac.transportSnapshot()
483 credentialMatch = oldSnap.transport != nil
484 }
485 break
486 }
487 sess.mu.Unlock()
488 }
489 // Teardown may have parked the credential while we resolved the session.
490 if d.parked[h.ResumeToken] != nil {
491 d.mu.Unlock()
492 return d.resumeParked(h, tr, sz)
493 }
494 if credentialMatch && h.RemoteTarget != nil && !d.remoteTargetMatchesSessionLocked(sess, *h.RemoteTarget) {
495 d.mu.Unlock()
496 return nil, nil, false, &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"}
497 }
498 d.mu.Unlock()
499
500 if clientMismatch {
501 reason := "client id mismatch"
502 d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "err", reason)
503 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
504 }
505 if !credentialMatch {
506 // Detach may have won and not published parked yet; wait out that gap
507 // for the exact same-client credential, then try the parked path.
508 _ = d.waitParkingInFlight(h)
509 return d.resumeParked(h, tr, sz)
510 }
511
512 if d.beforeMarkParkingInFlight != nil {
513 d.beforeMarkParkingInFlight()
514 }
515 // Advertise parking before detach so a concurrent same-token resume never
516 // observes both the live seat and parking/parked registries empty. Late
517 // publication after terminal detach/session removal returns 0.
518 parkingToken := d.markParkingInFlight(sess, ac)
519 if !d.detachIfCurrentTransport(sess, ac, oldSnap) {
520 d.clearParkingInFlightIfAbandoned(sess, ac, parkingToken)
521 // A newer owner, rebound link, or teardown won; wait if teardown is
522 // still publishing the park, then try the parked path.
523 _ = d.waitParkingInFlight(h)
524 return d.resumeParked(h, tr, sz)
525 }
526 if d.afterResumeLiveDetach != nil {
527 d.afterResumeLiveDetach()
528 }
529 if rc := sess.renderCoordinator(); rc != nil {
530 rc.noteDetach(ac)
531 }
532 d.recalculateSessionGeometryAndInvalidate(sess, nil, "resume.go")
533 d.unregisterPreview(ac)
534 if !d.parkAttachment(sess, ac) {
535 // Detach already published; retire the captured link exactly once so
536 // shutdown cannot leave an orphaned open transport with no owner.
537 d.clearParkingInFlight(d.resumeTokenSnapshot(ac), ac)
538 d.resetScreenDefaultColors(sess)
539 _ = ac.closeCapturedTransport(ac.revokeTransport(oldSnap.transport))
540 d.mu.Lock()
541 closing := d.closing
542 d.mu.Unlock()
543 if closing {
544 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
545 }
546 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
547 }
548 _ = ac.closeCapturedTransport(oldSnap.transport)
549 d.log.Info("live resume credential parked for reconnect", "session", sess.nameSnapshot())
550 return d.resumeParked(h, tr, sz)
551}
552
553// resumeParked acquires the parked client's output lock before the daemon
554// registry lock, preserving the global sendMu > Daemon.mu ordering. The parked
555// entry is revalidated after both locks are held because it may expire between
556// the initial lookup and lock acquisition.
557func (d *Daemon) resumeParked(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) {
558 d.mu.Lock()
559 parked := d.parked[h.ResumeToken]
560 d.mu.Unlock()
561 if parked == nil || h.ResumeToken == 0 {
562 return nil, nil, false, nil
563 }
564
565 ac := parked.ac
566 if d.beforeResumeParkedSendMu != nil {
567 d.beforeResumeParkedSendMu()
568 }
569 ac.sendMu.Lock()
570 d.mu.Lock()
571 if d.closing {
572 d.mu.Unlock()
573 ac.sendMu.Unlock()
574 return nil, nil, false, &protoErr{ports.ErrServerShutdown, "daemon is shutting down"}
575 }
576 if d.parked[h.ResumeToken] != parked || parked.claimed {
577 if d.sessions[parked.sess.id] == parked.sess {
578 d.mu.Unlock()
579 ac.sendMu.Unlock()
580 return nil, nil, false, errResumeTokenLifecycleRace
581 }
582 d.mu.Unlock()
583 ac.sendMu.Unlock()
584 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
585 }
586 sess, resumed, ok, err := d.resumeParkedLocked(h, tr, sz)
587 d.mu.Unlock()
588 ac.sendMu.Unlock()
589 if err != nil && resumed != nil {
590 d.abortResumeClaim(resumed)
591 }
592 return sess, resumed, ok, err
593}
594
595// resumeParkedLocked completes a validated resume. Caller holds both ac.sendMu
596// and d.mu in that order.
597func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain.Size) (*session, *attachedClient, bool, error) {
598 parked := d.parked[h.ResumeToken]
599 if parked == nil || h.ResumeToken == 0 {
600 return nil, nil, false, nil
601 }
602 sess := parked.sess
603 registered := d.sessions[sess.id] == sess
604 if !registered {
605 d.removeParkedLocked(h.ResumeToken, parked)
606 d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "registered", false)
607 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
608 }
609 ac := parked.ac
610 if parked.claimed {
611 return nil, nil, false, errResumeTokenLifecycleRace
612 }
613 if ac.clientID != h.ClientID {
614 reason := "client id mismatch"
615 d.log.Warn("resume rejected", "session", sess.nameSnapshot(), "err", reason)
616 return nil, nil, false, &protoErr{ports.ErrNoSuchSession, "resume token is no longer valid"}
617 }
618 if h.RemoteTarget != nil && !d.remoteTargetMatchesSessionLocked(sess, *h.RemoteTarget) {
619 return nil, nil, false, &protoErr{ports.ErrNoSuchTarget, "remote target no longer matches resumed session"}
620 }
621 // Claim the attachment while retaining the old credential until Welcome is
622 // accepted. A failed handshake can therefore restore this exact parked
623 // entry; another resume sees claimed and loses atomically.
624 parked.claimed = true
625 ac.resumeClaimToken = h.ResumeToken
626 // The parked attachment has no session owner and cannot paint. Retire the
627 // abandoned transport's output chain before binding the replacement so the
628 // mandatory first paint cannot be blocked by ACKs that died with the link.
629 ac.rebaseOutput()
630 ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
631 ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding)
632 ac.replaceTransport(tr)
633 ac.setSize(sz)
634 ac.resumeToken = d.nextResumeTokenLocked()
635 ac.parked = false
636 // The resumed session's snapshot is the sole source for future PTY children.
637 // Existing PTYs retain the environment they were started with.
638 sess.mu.Lock()
639 if h.EnvironmentPolicy != ports.EnvironmentPolicyDaemonOwned {
640 sess.env = copyEnvironment(h.Env)
641 }
642 sess.terminal = terminalEnv{TrueColor: h.TrueColor}
643 sess.mu.Unlock()
644 preferredTabIndex := preferredTabIndex(sess, h.PreferredTabID)
645 // Resume preparation used sendMu -> d.mu, but freeze/drain must run with
646 // neither held. Reacquire them before returning to preserve this helper's
647 // locked-caller contract.
648 d.mu.Unlock()
649 ac.sendMu.Unlock()
650 transition, err := d.transitionAttachment(attachmentTransitionRequest{
651 target: sess,
652 next: ac,
653
654 expectedTransport: ac.transportSnapshot(),
655 activateTargetTab: preferredTabIndex >= 0,
656 targetTabIndex: preferredTabIndex,
657 ready: false,
658 })
659 ac.sendMu.Lock()
660 d.mu.Lock()
661 if err != nil {
662 return sess, ac, false, &protoErr{ports.ErrInternal, "resume attachment transition failed"}
663 }
664 d.deferAttachmentTransitionCleanups(transition)
665 d.touchMRU(sess)
666 d.log.Info("client resumed", "session", sess.nameSnapshot())
667 return sess, ac, true, nil
668}