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