1// Package daemon holds vev's server-side session multiplexer use case.
2//
3// Lock ordering: never acquire sendMu or session/daemon locks while holding a
4// pane lock. Coordinator callbacks and timer methods run without coordinator.mu;
5// see the lock-specific comments on attachedClient and renderCoordinator.
6// Attachment lifecycle gates order after daemon, session, routing, coordinator,
7// and pane locks: a daemon or session owner may briefly take one
8// lifecycle.mu to observe committed activity (see
9// sessionInteractivelyAttachedLocked), but must never freeze, drain, or wait on
10// a gate while holding any architecture lock. Freeze/drain always runs with
11// every architecture lock released.
12//
13// Daemon-level gate order is moveLifecycleMu before d.mu: reserveMoveLifecycles
14// holds moveLifecycleMu while taking d.mu to reserve the KillAll purge-gate
15// counter, and releaseMoveAdmissionLocked does the same while releasing it.
16// closeMoveLifecycles deliberately drops moveLifecycleMu before taking d.mu, so
17// no path holds d.mu and then waits on moveLifecycleMu. Consequently
18// moveLifecycleReservation.Release must never be called with d.mu held: it takes
19// moveLifecycleMu first, releases both the move-gate and purge-gate reservations
20// in that order, and only then drops moveLifecycleMu.
21//
22// Snapshot worker locks (snapshotWorker in snapshot_worker_state.go):
23// snapshotWorker.mu (d.snapshots.mu) is a leaf with respect to d.mu and the
24// session (mu), tab, and pane locks: no path acquires any of them while holding
25// it, and the capture, queue, and worker code never takes d.mu. The only nested
26// acquisition is a session's snapshotMu inside it (enqueueFinalSnapshotCapture
27// finishes a replaced capture), so snapshotMu holders must release snapshotMu
28// before taking snapshotWorker.mu, as finishSnapshotCapture does. That nested
29// finishSnapshotCapture(replaced, false) cannot re-enter snapshotWorker.mu:
30// final-queue captures are never normalWorkerAdmitted, and succeeded=false
31// suppresses the forced-successor scheduling path.
32// snapshotWorker.noticeMu guards the active persistence-failure signature; it
33// is a leaf and is never held together with snapshotWorker.mu. Restoration
34// takes d.mu only outside snapshotWorker.mu.
35//
36// paletteHistory.mu guards the daemon-wide recent-command list. It is a leaf:
37// it is only held to copy or rewrite that slice, never while calling out.
38//
39// purgeAllMu serializes whole KillAll purges (purgeAllSessions). It is the
40// outermost daemon-level lock on that path: the owner takes d.mu (briefly, to
41// open and close the purge admission gate) and later per-session teardown locks
42// while holding it. No path acquires purgeAllMu while holding d.mu,
43// moveLifecycleMu, a session lock, or a pane lock. It is not an admission gate;
44// admission is the d.mu-guarded purgeAdmission* state.
45//
46// attnMu guards only the attention animation frame counter (animFrame). It is
47// a leaf: attentionFrame, advanceAttentionFrame, and setAttentionFrame take it
48// for the read or write and call nothing while holding it.
49//
50// Naming rule for the Locked suffix: on a Daemon method (or a free function
51// taking a Daemon-owned structure) a ...Locked suffix means the caller holds
52// d.mu. On a method of any other type, it means the caller holds that
53// receiver's own mutex (session.mu, tab.mu, pane.mu, and so on). Whenever a
54// ...Locked function instead requires a different lock (for example
55// overlayRuntime.copyMu, snapshotWorker.mu, moveLifecycleMu, or
56// barScriptState.mu), its doc comment names that lock explicitly.
57package daemon
58
59import (
60 "errors"
61 "sync"
62 "sync/atomic"
63 "time"
64
65 "github.com/bnema/vev/internal/domain"
66 "github.com/bnema/vev/internal/domain/terminalcap"
67 "github.com/bnema/vev/internal/ports"
68 "github.com/bnema/vev/internal/protocol"
69 "github.com/bnema/vev/internal/usecase/keys"
70 "github.com/bnema/vev/internal/usecase/mouse"
71 themeui "github.com/bnema/vev/internal/usecase/theme"
72)
73
74type transportSnapshot struct {
75 transport ports.ServerConnection
76 incarnation uint64
77}
78
79// appliedTheme is the single immutable chrome snapshot consumed by rendering.
80// Raw retains terminal defaults for panes; Resolved belongs exclusively to vev
81// chrome. Generation changes whenever either terminal input or policy changes.
82type appliedTheme struct {
83 Raw themeui.Theme
84 Resolved themeui.ResolvedTheme
85 Generation uint64
86}
87
88type attachedClient struct {
89 tr ports.ServerConnection
90 transportIncarnation uint64
91 output *attachmentOutput
92 overlays *overlayRuntime
93 overlayOnce sync.Once
94 clientID [16]byte
95 terminalCapabilities terminalcap.Capabilities
96 navigationCapabilities protocol.NavigationCapabilities
97 // lifecycle is the sole authority for attachment capability publication,
98 // effect admission, transition freeze/drain, and connection generation.
99 lifecycle attachmentLifecycle
100 resumeCapable bool
101 resumeToken uint64
102 // resumeClaimToken is non-zero only between a parked resume claim and its
103 // successful Welcome. It lets a failed pre-claim handshake restore the old
104 // credential instead of consuming it.
105 resumeClaimToken uint64
106 parked bool
107 // echoAck is the newest client InputSeq applied at least echoAckDelay ago;
108 // echo owns its updates.
109 echoAck atomic.Uint64
110 echo echoAckTracker
111 // focus holds the domain.TerminalFocus the client last reported. It is an
112 // atomic so any daemon path reads it without taking a lock; see
113 // terminalFocus.
114 focus atomic.Uint32
115 // ctrlHeld is whether the client last reported Ctrl held on its own; the
116 // status bar then shows recent-session ranks.
117 ctrlHeld atomic.Bool
118 // clientPID is the local client process from Hello, 0 when remote. It is
119 // atomic because a resume replaces it while bar scripts read it.
120 clientPID atomic.Uint32
121 // clientEnv is the immutable client-owned environment from the latest
122 // Hello, or nil when the daemon owns the environment (remote). A resume
123 // replaces it, so a remote resume of a local attachment clears it. Session
124 // handoffs refresh the target from it. Atomic because a resume replaces it
125 // while handoffs read it.
126 clientEnv atomic.Pointer[[]string]
127 // prepareFailureFallback prevents a direct fallback paint from recursively
128 // reporting the same failed prepare through its notice repaint. It is only
129 // needed while no render coordinator is installed.
130 prepareFailureFallback atomic.Bool
131 // render is the attachment's composition state. Only touched under sendMu.
132 render attachmentRenderState
133 // sizeMu lets shared-geometry snapshots read attachment windows without
134 // waiting behind a blocked transport send.
135 sizeMu sync.RWMutex
136 geometry domain.Geometry // controlling-terminal geometry for this Attachment
137 keys *keys.Router
138 // view is attachment-local navigation state. It is never inferred from a
139 // session-wide active tab, so multiple attachments can observe different
140 // tabs and panes without changing shared session ownership.
141 viewMu sync.Mutex
142 view attachmentView
143 sess Guarded[*session]
144 mouseScan mouse.Scanner
145 themeMu sync.Mutex
146 clientTheme themeui.Theme
147 appliedTheme appliedTheme
148 renderStages renderStageHooks // optional render and handoff observability hooks
149 linkMu sync.Mutex
150 sendMu sync.Mutex
151 afterFrame afterFrameQueue
152 routeMu sync.RWMutex
153 routeSnapshot protocol.RecentRouteSnapshot
154 pendingRouteIdentity bool
155 samePeerOfferMu sync.Mutex
156 samePeerOffer *protocol.ExactSessionTarget
157 routeAttentionSubscription protocol.RouteAttentionSubscription
158 routeSubscriptionTransport transportSnapshot
159 routeObservationAfter map[protocol.RouteAttentionTarget]time.Time
160 // routeCreatedSession marks a session created by this attachment's route.
161 // A handshake that never commits Welcome must tear down that exact empty
162 // session, while an attachment routed to an existing session must not.
163 routeCreatedSession bool
164 // routeSessionPurge distinguishes a brand-new session from a restored
165 // stopped session when failed-handshake cleanup removes the live instance.
166 routeSessionPurge bool
167 // beforeAttachmentCapabilityValidation is a deterministic lifecycle-race Seam
168 // used only by package tests.
169 beforeAttachmentCapabilityValidation func()
170}
171
172type cursorOut struct {
173 valid bool
174 hidden bool
175 row int
176 col int
177 style int
178 hasStyle bool
179}
180
181// pendingByteTimer is retained for overlay input timers. Resize timing belongs
182// exclusively to renderCoordinator.
183type pendingByteTimer struct {
184 timer ports.Timer
185 done chan struct{}
186}
187
188func (p *pendingByteTimer) retain(clock ports.Clock, delay time.Duration, onFire func(ports.Timer)) {
189 p.timer = clock.NewTimer(delay)
190 p.done = make(chan struct{})
191 go func(timer ports.Timer, done <-chan struct{}) {
192 select {
193 case <-timer.C():
194 case <-done:
195 return
196 }
197 onFire(timer)
198 }(p.timer, p.done)
199}
200
201func (p *pendingByteTimer) stop() {
202 if p.timer != nil {
203 p.timer.Stop()
204 p.timer = nil
205 }
206 if p.done != nil {
207 close(p.done)
208 p.done = nil
209 }
210}
211
212func (ac *attachedClient) initOverlays() {
213 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
214}
215
216func (ac *attachedClient) sizeSnapshot() domain.Size {
217 if ac == nil {
218 return domain.Size{}
219 }
220 ac.sizeMu.RLock()
221 defer ac.sizeMu.RUnlock()
222 return ac.geometry.Size
223}
224
225func (ac *attachedClient) setSize(size domain.Size) {
226 if ac == nil {
227 return
228 }
229 ac.sizeMu.Lock()
230 ac.geometry.Size = size
231 ac.geometry = ac.geometry.NormalizePixels()
232 ac.sizeMu.Unlock()
233}
234
235func (ac *attachedClient) geometrySnapshot() domain.Geometry {
236 if ac == nil {
237 return domain.Geometry{}
238 }
239 ac.sizeMu.RLock()
240 defer ac.sizeMu.RUnlock()
241 if ac.geometry.Valid() {
242 return ac.geometry
243 }
244 // Pixels only describe a valid cell size; never hand them out without one.
245 return domain.Geometry{Size: ac.geometry.Size}
246}
247
248func (ac *attachedClient) setGeometry(geometry domain.Geometry) {
249 if ac == nil {
250 return
251 }
252 geometry = geometry.NormalizePixels()
253 ac.sizeMu.Lock()
254 ac.geometry = geometry
255 ac.sizeMu.Unlock()
256}
257
258// currentSession returns the attachment's owning local session.
259func (ac *attachedClient) currentSession() *session {
260 if ac == nil {
261 return nil
262 }
263 return ac.sess.Get()
264}
265
266// currentAttachmentSession is the explicit ownership-boundary alias used by
267// transition and rendering paths.
268func (ac *attachedClient) currentAttachmentSession() *session {
269 return ac.currentSession()
270}
271
272func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
273
274// pruneCaptureFrames releases snapshots for panes that have left their owner.
275// Callers must not hold daemon, session, tab, or pane locks.
276func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) {
277 if ac == nil || len(panes) == 0 {
278 return
279 }
280 ac.sendMu.Lock()
281 ac.render.forgetPanes(panes...)
282 ac.sendMu.Unlock()
283}
284
285// clearCaptureFrames releases all pane snapshots when the attachment no longer
286// owns a session.
287func (ac *attachedClient) clearCaptureFrames() {
288 if ac == nil {
289 return
290 }
291 ac.sendMu.Lock()
292 ac.render.forgetAllPanes()
293 ac.sendMu.Unlock()
294}
295
296func (ac *attachedClient) getAppliedTheme() appliedTheme {
297 ac.themeMu.Lock()
298 defer ac.themeMu.Unlock()
299 applied := ac.appliedTheme
300 if applied.Generation == 0 {
301 // An unattached client has no terminal report yet. Reuse the static
302 // neutral cache rather than resolving from a render path.
303 applied.Resolved = themeui.ResolvedTheme{Theme: applied.Raw, Styles: fallbackStylesFor(ac.terminalCapabilities.Color)}
304 }
305 return applied
306}
307
308func (ac *attachedClient) getClientTheme() themeui.Theme {
309 ac.themeMu.Lock()
310 defer ac.themeMu.Unlock()
311 return ac.clientTheme
312}
313
314func (ac *attachedClient) setAppliedTheme(next appliedTheme) {
315 ac.themeMu.Lock()
316 next.Generation = ac.appliedTheme.Generation + 1
317 ac.appliedTheme = next
318 ac.themeMu.Unlock()
319}
320
321func (ac *attachedClient) setClientTheme(t themeui.Theme) {
322 ac.themeMu.Lock()
323 ac.clientTheme = t
324 ac.themeMu.Unlock()
325}
326
327func (ac *attachedClient) transport() ports.ServerConnection {
328 ac.linkMu.Lock()
329 defer ac.linkMu.Unlock()
330 return ac.tr
331}
332
333func (ac *attachedClient) replaceTransport(tr ports.ServerConnection) {
334 ac.linkMu.Lock()
335 ac.tr = tr
336 ac.transportIncarnation++
337 ac.linkMu.Unlock()
338}
339
340// revokeTransport removes tr only if it is still this attachment's current
341// link. It returns the revoked transport, so asynchronous retirement can
342// close exactly that link without touching a later connection incarnation.
343func (ac *attachedClient) revokeTransport(tr ports.ServerConnection) ports.ServerConnection {
344 if tr == nil {
345 return nil
346 }
347 ac.linkMu.Lock()
348 if ac.tr != tr {
349 ac.linkMu.Unlock()
350 return nil
351 }
352 ac.tr = nil
353 ac.transportIncarnation++
354 ac.linkMu.Unlock()
355 return tr
356}
357
358// transportSnapshot binds a send to one concrete link lifetime. Callers must
359// revalidate it after acquiring sendMu before writing to that link.
360func (ac *attachedClient) transportSnapshot() transportSnapshot {
361 ac.linkMu.Lock()
362 defer ac.linkMu.Unlock()
363 return transportSnapshot{transport: ac.tr, incarnation: ac.transportIncarnation}
364}
365
366func (ac *attachedClient) transportSnapshotCurrent(expected transportSnapshot) bool {
367 ac.linkMu.Lock()
368 defer ac.linkMu.Unlock()
369 return ac.tr == expected.transport && ac.transportIncarnation == expected.incarnation
370}
371
372func (ac *attachedClient) closeCapturedTransport(tr ports.ServerConnection) error {
373 if tr == nil {
374 return nil
375 }
376 return tr.Close()
377}
378
379func (ac *attachedClient) transportIs(tr ports.ServerConnection) bool {
380 ac.linkMu.Lock()
381 defer ac.linkMu.Unlock()
382 return ac.tr == tr
383}
384
385func (ac *attachedClient) currentTransportIs(tr ports.ServerConnection) bool {
386 return tr != nil && ac.transportIs(tr)
387}
388
389// setRouteSnapshot records a client-owned route view and reports whether a
390// rename identity was deferred until that first usable publication.
391func (ac *attachedClient) setRouteSnapshot(snapshot protocol.RecentRouteSnapshot) bool {
392 snapshot.Entries = append([]protocol.RecentRouteEntry(nil), snapshot.Entries...)
393 ac.routeMu.Lock()
394 ac.routeSnapshot = snapshot
395 replayPendingIdentity := snapshot.Generation != 0 && ac.pendingRouteIdentity
396 if replayPendingIdentity {
397 ac.pendingRouteIdentity = false
398 }
399 ac.routeMu.Unlock()
400 return replayPendingIdentity
401}
402
403// deferRouteIdentityUntilSnapshot records a rename that happens before the
404// client has published its route ledger. It returns false once a snapshot is
405// already available and the identity may be sent immediately.
406func (ac *attachedClient) deferRouteIdentityUntilSnapshot() bool {
407 ac.routeMu.Lock()
408 defer ac.routeMu.Unlock()
409 if ac.routeSnapshot.Generation != 0 {
410 return false
411 }
412 ac.pendingRouteIdentity = true
413 return true
414}
415
416func (ac *attachedClient) routeSnapshotCopy() protocol.RecentRouteSnapshot {
417 ac.routeMu.RLock()
418 defer ac.routeMu.RUnlock()
419 snapshot := ac.routeSnapshot
420 snapshot.Entries = append([]protocol.RecentRouteEntry(nil), snapshot.Entries...)
421 return snapshot
422}
423
424func (ac *attachedClient) setRouteAttentionSubscription(subscription protocol.RouteAttentionSubscription, expected transportSnapshot, now time.Time) {
425 subscription.Targets = append([]protocol.RouteAttentionTarget(nil), subscription.Targets...)
426 ac.routeMu.Lock()
427 defer ac.routeMu.Unlock()
428 after := make(map[protocol.RouteAttentionTarget]time.Time, len(subscription.Targets))
429 for _, target := range subscription.Targets {
430 admitted := now
431 // Preserve the causal observation fence across unchanged republishes;
432 // a new connection must establish a fresh post-Welcome subscription.
433 if prior, ok := ac.routeObservationAfter[target]; ok && ac.routeSubscriptionTransport == expected {
434 admitted = prior
435 }
436 after[target] = admitted
437 }
438 ac.routeObservationAfter = after
439 ac.routeSubscriptionTransport = expected
440 ac.routeAttentionSubscription = subscription
441}
442
443func (ac *attachedClient) routeAttentionTarget(ref protocol.RouteRef) (protocol.RouteAttentionTarget, bool) {
444 ac.routeMu.RLock()
445 defer ac.routeMu.RUnlock()
446 for _, target := range ac.routeAttentionSubscription.Targets {
447 if target.Ref == ref {
448 return target, true
449 }
450 }
451 return protocol.RouteAttentionTarget{}, false
452}
453
454// ackOutputState applies one cumulative output ACK under the send boundary and
455// reports whether it advanced the paint window.
456func (ac *attachedClient) ackOutputState(epoch, state uint64) bool {
457 ac.sendMu.Lock()
458 defer ac.sendMu.Unlock()
459 return ac.output != nil && ac.output.ack(epoch, state)
460}
461
462// rebaseOutput resets only this attachment's output representations. Callers
463// hold sendMu (or the activation barrier).
464func (ac *attachedClient) rebaseOutput() {
465 if ac == nil || ac.output == nil {
466 return
467 }
468 ac.output.rebaseAttachment()
469}
470
471// restartOutputLocked starts a new output epoch and drops the composition it
472// would otherwise diff against, so the next paint is a complete frame.
473// dropPanes also drops per-pane capture snapshots, which is required when the
474// panes may belong to another session or a different window size. Caller
475// holds sendMu.
476func (ac *attachedClient) restartOutputLocked(dropPanes bool) {
477 ac.rebaseOutput()
478 ac.render.forgetComposition()
479 if dropPanes {
480 ac.render.forgetAllPanes()
481 }
482}
483
484// publishWindowLocked publishes a new window height to the attachment view and
485// restarts output for it. Caller holds sendMu.
486func (ac *attachedClient) publishWindowLocked(rows int, dropPanes bool) {
487 view := ac.viewSnapshot()
488 view.windowRows = rows
489 view.windowSet = true
490 view.windowTop = 0
491 view.revision++
492 ac.publishView(view)
493 ac.restartOutputLocked(dropPanes)
494}
495
496var errTransportReplaced = errors.New("client transport was replaced")
497
498// sendExpectedTransport writes only when expected is still the attachment's
499// current transport incarnation. It preserves sendMu -> linkMu lock ordering.
500func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, message protocol.ServerMessage) error {
501 ac.sendMu.Lock()
502 defer ac.sendMu.Unlock()
503 if !ac.transportSnapshotCurrent(expected) {
504 return errTransportReplaced
505 }
506 if expected.transport == nil {
507 return errors.New("client transport is nil")
508 }
509 return expected.transport.SendServer(message)
510}
511
512// beginExpectedTransportSendLocked validates that expected is still the
513// attachment's current transport incarnation and admits ticket's interruptible
514// transport send. The caller must already hold ac.sendMu and, on success, owns
515// both the send and its matching ticket.endTransportSend.
516func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSnapshot, ticket *attachmentEffect) error {
517 if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {
518 return errTransportReplaced
519 }
520 if ticket != nil && (ticket.ended.Load() || !ticket.beginTransportSend(expected)) {
521 return errAttachmentTransition
522 }
523 return nil
524}
525
526func (ac *attachedClient) sendExpectedTransportForAttachment(expected transportSnapshot, message protocol.ServerMessage, ticket *attachmentEffect) error {
527 ac.sendMu.Lock()
528 defer ac.sendMu.Unlock()
529 // A attachment-bound send requires a live ticket, and reports every rejection,
530 // including a replaced transport, as a lost attachment transition.
531 if ticket == nil {
532 return errAttachmentTransition
533 }
534 if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil {
535 return errAttachmentTransition
536 }
537 err := expected.transport.SendServer(message)
538 if err != nil {
539 ticket.reportTransportFailure(expected)
540 }
541 ticket.endTransportSend()
542 return err
543}
544
545// boundedSend sends f to ac with a deadline watchdog: if the send (including
546// waiting on sendMu behind a wedged paint) does not complete within
547// detachNotifyTimeout, the transport is force-closed, failing the in-flight
548// write. Detach/kill/shutdown paths use this so they are never gated on a
549// client that has stopped draining its socket.
550func (d *Daemon) boundedSend(ac *attachedClient, message protocol.ServerMessage) {
551 _ = d.boundedSendErr(ac, message)
552}
553
554func (d *Daemon) boundedSendErr(ac *attachedClient, message protocol.ServerMessage) error {
555 expected := ac.transportSnapshot()
556 if expected.transport == nil {
557 return errors.New("client transport is nil")
558 }
559 tr, err := d.boundedSendWith(expected.transport, func() error {
560 return ac.sendExpectedTransport(expected, message)
561 })
562 if err != nil && errors.Is(err, errSendTimedOut) {
563 _ = ac.closeCapturedTransport(tr)
564 }
565 return err
566}
567
568func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
569 _, err := d.boundedSendOutputErrTransport(ac, b)
570 return err
571}
572
573func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.ServerConnection, error) {
574 expected := ac.transportSnapshot()
575 if expected.transport == nil {
576 return nil, errors.New("client transport is nil")
577 }
578 if expected.transport.Capabilities().OwnedSynchronousSend {
579 ac.sendMu.Lock()
580 defer ac.sendMu.Unlock()
581 if !ac.transportSnapshotCurrent(expected) {
582 return expected.transport, errTransportReplaced
583 }
584 ac.output.lockView()
585 defer ac.output.unlockView()
586 output, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
587 if err == nil {
588 err = expected.transport.SendOutputSynchronous(output)
589 }
590 if err == nil {
591 ac.output.sideEffectSentLocked(output)
592 }
593 return expected.transport, err
594 }
595 return d.boundedSendWith(expected.transport, func() error {
596 ac.sendMu.Lock()
597 defer ac.sendMu.Unlock()
598 if !ac.transportSnapshotCurrent(expected) {
599 return errTransportReplaced
600 }
601 ac.output.lockView()
602 defer ac.output.unlockView()
603 output, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
604 if err != nil {
605 return err
606 }
607 if err := expected.transport.SendOutput(output); err != nil {
608 return err
609 }
610 ac.output.sideEffectSentLocked(output)
611 return nil
612 })
613}
614
615var errSendTimedOut = errors.New("send timed out")
616
617func (d *Daemon) boundedSendWith(tr ports.ServerConnection, send func() error) (ports.ServerConnection, error) {
618 return d.boundedSendWithTimeout(detachNotifyTimeout, tr, send)
619}
620
621func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, tr ports.ServerConnection, send func() error) (ports.ServerConnection, error) {
622 timer := d.clock.NewTimer(timeout)
623 result := make(chan error, 1)
624 go func() {
625 result <- send()
626 }()
627 select {
628 case err := <-result:
629 timer.Stop()
630 return tr, err
631 case <-timer.C():
632 select {
633 case err := <-result:
634 return tr, err
635 default:
636 }
637 d.log.Warn("bounded send timed out; force closing client transport")
638 return tr, errSendTimedOut
639 }
640}
641
642type detachedAttachmentSnapshot struct {
643 ac *attachedClient
644 transport transportSnapshot
645}
646
647// notifyDetachedSnapshotAsync sends and closes only the transport incarnation
648// captured by atomic session teardown. It cannot touch a later link installed
649// on the same attachedClient object.
650func (d *Daemon) notifyDetachedSnapshotAsync(snapshot detachedAttachmentSnapshot, reason uint8) {
651 if snapshot.ac == nil || snapshot.transport.transport == nil {
652 return
653 }
654 done := make(chan struct{})
655 d.mu.Lock()
656 // Prune completed entries so the slice stays bounded by the number of
657 // notifications actually in flight.
658 kept := d.notifies[:0]
659 for _, c := range d.notifies {
660 select {
661 case <-c:
662 default:
663 kept = append(kept, c)
664 }
665 }
666 d.notifies = append(kept, done)
667 d.mu.Unlock()
668
669 go func() {
670 defer close(done)
671 _, _ = d.boundedSendWith(snapshot.transport.transport, func() error {
672 return snapshot.ac.sendExpectedTransport(snapshot.transport, serverDetached(reason))
673 })
674 revoked := snapshot.ac.revokeTransport(snapshot.transport.transport)
675 if revoked == nil {
676 revoked = snapshot.transport.transport
677 }
678 _ = snapshot.ac.closeCapturedTransport(revoked)
679 d.log.Info("client detached", "reason", reason)
680 }()
681}
682
683// waitNotifies blocks until every Detached notification in flight at the time
684// of the call has completed. Each is deadline-bounded (boundedSend), so this
685// wait is bounded too.
686func (d *Daemon) waitNotifies() {
687 for _, c := range d.notifiesSnapshot() {
688 <-c
689 }
690}
691
692func (d *Daemon) notifiesSnapshot() []chan struct{} {
693 d.mu.Lock()
694 defer d.mu.Unlock()
695 snapshot := make([]chan struct{}, len(d.notifies))
696 copy(snapshot, d.notifies)
697 return snapshot
698}
699
700type attachClientOptions struct {
701 clientID [16]byte
702 kittyKeyboard bool
703 resumeCapable bool
704 maxOutputInFlight uint8
705 terminalCapabilities terminalcap.Capabilities
706 capabilitiesSet bool
707 navigationCapabilities protocol.NavigationCapabilities
708 terminalFocus domain.TerminalFocus
709 clientPID uint32
710 clientEnv []string
711}
712
713func (d *Daemon) attachClient(sess *session, tr ports.ServerConnection, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
714 d.mu.Lock()
715 ac := d.prepareAttachedClientLocked(sess, tr, domain.Geometry{Size: sz}, opts)
716 d.mu.Unlock()
717 result, err := d.transitionAttachment(attachmentTransitionRequest{
718 target: sess,
719 next: ac,
720
721 expectedTransport: ac.transportSnapshot(),
722 ready: false,
723 })
724 if err != nil {
725 d.discardAttachmentOutput(ac)
726 if tr != nil {
727 _ = tr.Close()
728 }
729 return nil, err
730 }
731 d.finishAttachedClient(sess, ac, opts)
732 d.deferAttachmentTransitionCleanups(result)
733 return ac, nil
734}
735
736// colorDowngradeNotice reports the toast shown when a declared (not forced)
737// terminal renders with fewer than TrueColor colors. A forced mode is a user
738// choice and never warrants a notice.
739func colorDowngradeNotice(color terminalcap.ColorCapabilities) (string, bool) {
740 if color.Source != terminalcap.SourceDeclared {
741 return "", false
742 }
743 switch color.Mode {
744 case terminalcap.ANSI256:
745 return "Terminal supports 256 colors; vev UI colors are reduced.", true
746 case terminalcap.ANSI16:
747 return "Terminal supports 16 colors; vev UI colors are reduced.", true
748 case terminalcap.Monochrome:
749 return "Terminal has no color support; vev UI uses bold and reverse.", true
750 default:
751 return "", false
752 }
753}
754
755// publishColorDowngradeNotice toasts ac when its terminal declared a reduced
756// color mode (see colorDowngradeNotice).
757func (d *Daemon) publishColorDowngradeNotice(sess *session, ac *attachedClient) {
758 message, ok := colorDowngradeNotice(ac.terminalCapabilities.Color)
759 if !ok {
760 return
761 }
762 d.publishToast(ac, domain.Notification{
763 Code: domain.NoticeUser,
764 Severity: domain.NoticeWarn,
765 Message: message,
766 Time: d.clock.Now(),
767 Count: 1,
768 SessionID: sess.id,
769 })
770}
771
772func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
773 d.touchMRU(sess)
774 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
775 d.publishColorDowngradeNotice(sess, ac)
776 d.applyHostTheme(sess, ac, themeui.Theme{}, true)
777 if !ac.terminalCapabilities.SupportsKittyGraphics() && sessionHasKittyGraphics(sess) {
778 d.warnUnsupportedGraphics(ac)
779 }
780}
781
782// prepareAttachedClientLocked allocates one detached attachment. Caller holds
783// d.mu only to allocate its resume token; attachment publication happens later after
784// the caller releases every architecture lock.
785func (d *Daemon) prepareAttachedClientLocked(sess *session, tr ports.ServerConnection, geometry domain.Geometry, opts attachClientOptions) *attachedClient {
786 resumeToken := uint64(0)
787 if opts.resumeCapable {
788 resumeToken = d.nextResumeTokenLocked()
789 }
790 if !opts.capabilitiesSet {
791 // The zero value is TrueColor: manually constructed attachments keep
792 // the historical renderer behavior.
793 opts.terminalCapabilities = terminalcap.Capabilities{}
794 }
795 output := newOutputStateStreamForCapabilities(opts.terminalCapabilities, opts.maxOutputInFlight)
796 geometry = geometry.NormalizePixels()
797 var graphicsOutput *graphicsOutputState
798 if opts.terminalCapabilities.SupportsKittyGraphics() {
799 if namespace, fence := d.reserveGraphicsNamespaceLeaseLocked(graphicsNamespaceKey(sess, opts.clientID)); namespace != 0 {
800 graphicsOutput = newGraphicsOutputStateWithLease(namespace, fence)
801 } else {
802 // Namespace leases that may have emitted are never reused without a
803 // terminal ACK. The bounded pool therefore fails closed to the normal
804 // text renderer instead of risking another attachment's objects.
805 opts.terminalCapabilities.KittyGraphics = false
806 }
807 }
808 output.graphicsOutput = graphicsOutput
809 ac := &attachedClient{
810 tr: tr,
811 output: output,
812 geometry: geometry,
813 view: attachmentView{windowRows: geometry.Rows, windowSet: true},
814 clientID: opts.clientID,
815 terminalCapabilities: opts.terminalCapabilities,
816 navigationCapabilities: opts.navigationCapabilities,
817 resumeCapable: opts.resumeCapable,
818 resumeToken: resumeToken,
819 }
820 ac.setTerminalFocus(opts.terminalFocus)
821 ac.clientPID.Store(opts.clientPID)
822 ac.setClientEnvironment(opts.clientEnv)
823 output.attachment = ac
824 ac.initOverlays()
825 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
826 ac.keys.SetKittyKeyboard(opts.kittyKeyboard)
827 return ac
828}
829
830// attachCoordinator installs the coordinator lease for an attachment. Session
831// membership remains independent, so installing one attachment never displaces
832// another.
833func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {
834 rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)
835 cleanup.finish()
836 return rc
837}
838
839func (d *Daemon) attachCoordinatorDeferred(sess *session, _ *attachedClient, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
840 rc := d.ensureRenderCoordinator(sess)
841 if current != nil {
842 rc.attachWithReadiness(current, ready)
843 }
844 return rc, renderLifecycleCleanup{}
845}
846
847// ensureRenderCoordinator publishes the session's single coordinator without
848// binding an attachment lease. Centralized attachment transitions bind the
849// lease later while their ordered session locks are still held.
850func (d *Daemon) ensureRenderCoordinator(sess *session) *renderCoordinator {
851 sess.mu.Lock()
852 defer sess.mu.Unlock()
853 return d.ensureRenderCoordinatorPrelocked(sess)
854}
855
856// ensureRenderCoordinatorPrelocked is the non-reentrant coordinator setup seam
857// for transactions that already hold sess.mu.
858func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordinator {
859 return d.ensureAttachmentRenderCoordinatorPrelocked(sess)
860}
861
862// ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an
863// exact session. The caller holds entry.core().mu.
864func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *renderCoordinator {
865 if rc := attachmentRenderCoordinator(entry); rc != nil {
866 return rc
867 }
868 var rc *renderCoordinator
869 rc = newRenderCoordinator(renderCoordinatorOptions{
870 clock: d.clock,
871 observer: d.runtimeObserver,
872 wake: func(w renderWake) {
873 if !rc.wakeCurrent(w) {
874 return
875 }
876 // The session snapshot is deterministic. Each paint revalidates the
877 // attachment's membership and transport fence after that snapshot, so
878 // a detached peer cannot stop remaining attachments receiving output.
879 attachments := entry.snapshotAttachments()
880 for _, attachment := range attachments {
881 if !rc.wakeCurrent(w) {
882 return
883 }
884 lease := w.attachmentLeases[attachment]
885 if lease == nil || !rc.leaseCurrent(lease, true) {
886 continue
887 }
888 // Pane damage is session-shared; every fan-out attachment needs a
889 // fresh capture because any peer may acknowledge the damage first.
890 // Each attachment owns its send lock, so a blocked peer cannot stall
891 // this fan-out or the PTY reader. A handshake-targeted wake is the
892 // one synchronous exception: its first paint must finish before the
893 // handshake deadline is released.
894 fanoutReset := w.reset || len(attachments) > 1
895 paint := func() {
896 if d.paint(entry, attachment, fanoutReset, lease) == paintBlockedCapacity {
897 rc.retryCapacity(w, lease)
898 }
899 }
900 if w.lease != nil && w.lease.attachment == attachment {
901 paint()
902 } else if len(attachments) > 1 {
903 go paint()
904 } else {
905 paint()
906 }
907 }
908 },
909 ackReadyFor: func(attached *attachedClient) bool {
910 // attachmentOutput publishes capacity atomically. Do not take
911 // attached.sendMu here: a slow transport may be holding it for an
912 // in-flight Send, and that peer must not gate healthy attachments.
913 return attached == nil || attached.output == nil || !attached.output.atCapacity()
914 },
915 })
916 installAttachmentRenderCoordinator(entry, rc)
917 return rc
918}
919
920// resetScreenDefaultColors clears the known default foreground/background
921// colors on every tab in sess. Called once a client has been detached, this
922// makes child OSC 10/11 queries go back to being swallowed (Known=false)
923// instead of being answered with the departed client's colors, which the
924// next client (with a different terminal theme) may never have reported.
925// Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu,
926// release it, then take each tb.mu in turn — never holding sess.mu and
927// tb.mu together. Guarded against a race with a newer attachment: if the
928// session has gained an attachment by the time sess.mu is taken, that
929// attachment has already run its attach-time reset, so this call must leave
930// the tabs alone rather than clobbering freshly applied colors.
931func (d *Daemon) resetScreenDefaultColors(sess *session) {
932 d.applyHostTheme(sess, nil, themeui.Theme{}, true)
933}
934
935// firstPaintForTransition is the only active post-transition paint entry
936// point. It rejects a stale attachment capability before rebasing output and carries
937// the exact coordinator lease through every resize and render effect.
938func (d *Daemon) firstPaintForTransition(capability attachmentCapability) bool {
939 if !capability.current() {
940 return false
941 }
942 effect, admitted := capability.ac.beginAttachmentEffect(capability)
943 if !admitted {
944 return false
945 }
946 defer effect.End()
947 if d.afterAttachmentEffectAdmitted != nil {
948 d.afterAttachmentEffectAdmitted(effect.capability())
949 }
950 if effect.rebase {
951 if d.beforeFirstPaintSendWait != nil {
952 d.beforeFirstPaintSendWait(effect.capability())
953 }
954 effect.ac.sendMu.Lock()
955 if effect.ac.renderStages.handoffRebase != nil {
956 effect.ac.renderStages.handoffRebase()
957 }
958 effect.ac.rebaseOutput()
959 // Captures belong to the old session even when pane-local IDs happen
960 // to be reused by the destination.
961 effect.ac.render.forgetAllPanes()
962 effect.ac.sendMu.Unlock()
963 }
964 if effect.sess == nil {
965 return false
966 }
967 d.firstPaintWithLease(effect.sess, effect.ac, effect.lease, true)
968 return true
969}
970
971// firstPaint guarantees the freshly attached client sees the full screen. The
972// shared session geometry is reconciled to the latest attachment claim before
973// each attachment gets its own outer-window paint.
974func (d *Daemon) firstPaint(sess *session, ac *attachedClient) {
975 d.firstPaintWithLease(sess, ac, nil, false)
976}
977
978func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, lease *attachmentLease, asyncGeometry bool) {
979 // Global notices raised while nothing was attached surface on this client.
980 // Drained before the early return below so a session without an active tab
981 // cannot swallow the queue.
982 d.drainPendingForFirstPaint(sess, ac)
983 tb := sess.tabForAttachment(ac)
984 if tb == nil {
985 return
986 }
987 if asyncGeometry {
988 // PTY.Resize is an external, non-cancelable operation. Do not hold the
989 // handshake on it; the first frame can use retained geometry and the
990 // shared invalidation will repaint every attachment after the claim wins.
991 sess.geometry.reconcileAndInvalidateAsync(d, sess, "client.go")
992 } else {
993 sess.geometry.reconcileAndInvalidate(d, sess, nil, "client.go")
994 }
995 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
996 // Floating activation may perform its own pane-local warmup after the shared
997 // session geometry has been reconciled above.
998 d.ensureFloatingWarm(sess, tb)
999 if lease == nil {
1000 d.invalidateRenderNow(sess, ac, true, "client.go")
1001 } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
1002 rc.fireCurrentForLease(lease)
1003 }
1004}
1005
1006// detachIfCurrent clears the client iff ac is the current one, reporting
1007
1008func themeFromMessage(msg protocol.Theme) themeui.Theme {
1009 return themeui.Theme{
1010 Foreground: msg.Foreground,
1011 Background: msg.Background,
1012 Palette: msg.Palette,
1013 PaletteKnown: msg.PaletteKnown,
1014 HasFG: msg.HasForeground,
1015 HasBG: msg.HasBackground,
1016 Known: msg.HasForeground && msg.HasBackground,
1017 SchemeKnown: msg.SchemeKnown,
1018 Light: msg.Light,
1019 }
1020}
1021
1022func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg protocol.Theme) {
1023 clientTheme := themeFromMessage(msg)
1024 ac.setClientTheme(clientTheme)
1025
1026 if !d.applyHostTheme(sess, ac, clientTheme, false) {
1027 return
1028 }
1029 d.invalidateRender(sess, ac, true, "client.go")
1030}
1031
1032func (d *Daemon) applyThemeForAttachment(effect *attachmentEffect, msg protocol.Theme) {
1033 if !effect.current() {
1034 return
1035 }
1036 clientTheme := themeFromMessage(msg)
1037 effect.ac.setClientTheme(clientTheme)
1038 sess := effect.sess
1039 if sess == nil || !effect.current() || !d.applyHostTheme(sess, effect.ac, clientTheme, false) {
1040 return
1041 }
1042 if !effect.current() {
1043 return
1044 }
1045 rc := sess.renderCoordinator()
1046 if effect.lease == nil || rc == nil {
1047 d.invalidateRender(sess, effect.ac, true, "client.go")
1048 return
1049 }
1050 rc.invalidateForLease(effect.ac, effect.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
1051}
1052
1053func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
1054 sess.geometry.requestResize(d, sess, ac, sz, false)
1055}
1056
1057// resizeAttachmentForLease updates the sender's window/view, then makes that
1058// attachment the latest shared tab/PTY geometry claimant. Callers hold the
1059// attachment effect admission; output state is rebased before any redraw so
1060// every subsequent frame starts a new epoch.
1061func (d *Daemon) resizeAttachmentForLease(effect *attachmentEffect, size domain.Size) bool {
1062 geometry := domain.Geometry{Size: size}
1063 if effect != nil && effect.ac != nil {
1064 // Direct size-only callers do not have a fresh pixel pair. Preserve the
1065 // attachment's last complete pair instead of downgrading its claim to
1066 // cell-only geometry.
1067 geometry = effect.ac.geometrySnapshot()
1068 geometry.Size = size
1069 }
1070 return d.resizeAttachmentGeometryForLease(effect, geometry)
1071}
1072
1073func (d *Daemon) resizeAttachmentGeometryForLease(effect *attachmentEffect, geometry domain.Geometry) bool {
1074 geometry = geometry.NormalizePixels()
1075 if !effect.current() || effect.ac == nil || !geometry.Valid() {
1076 return false
1077 }
1078 ac := effect.ac
1079 ac.sendMu.Lock()
1080 if !effect.current() || !ac.transportSnapshotCurrent(effect.transport) {
1081 ac.sendMu.Unlock()
1082 return false
1083 }
1084 previous := ac.geometrySnapshot()
1085 sameSize := previous.Size == geometry.Size
1086 sameGeometry := previous == geometry
1087 if !sameGeometry {
1088 ac.setGeometry(geometry)
1089 }
1090 if !sameSize {
1091 ac.publishWindowLocked(geometry.Rows, false)
1092 }
1093 ac.sendMu.Unlock()
1094
1095 if !sameSize && ac.overlays != nil && ac.overlays.pickerClientActive() {
1096 // A client-owned interaction renders from its own size: a resize
1097 // bumps the snapshot revision so the client never repaints against a
1098 // stale size epoch.
1099 d.refreshPickerSnapshot(ac)
1100 }
1101
1102 sess := effect.sess
1103 if sess == nil {
1104 return false
1105 }
1106 // Shared PTY/layout geometry follows the latest accepted attachment claim.
1107 // When this resize changes canonical geometry, the shared invalidation fans
1108 // out a fresh frame to every attachment; otherwise only this attachment
1109 // needs a viewport redraw.
1110 if sess.geometry.reconcileAndInvalidate(d, sess, ac, "client.go") {
1111 return true
1112 }
1113 if sameGeometry {
1114 return false
1115 }
1116 rc := sess.renderCoordinator()
1117 if rc == nil {
1118 return false
1119 }
1120 // Rendering is attachment-scoped. Schedule it through the coordinator so
1121 // rapid resize frames coalesce under the attachment lease and deadline.
1122 return rc.scheduleResizeForLease(geometry.Size, ac, effect.lease, func(uint64) {
1123 go d.paint(sess, ac, true, effect.lease)
1124 }) != 0
1125}
1126
1127// handleClientNotice maps the closed client-event enum to daemon-owned notice
1128// content. routingMu makes ownership validation and toast mutation one atomic
1129// attachment-routing operation: attachment publication also takes routingMu
1130// before changing membership. Never retain sess.mu while touching notice or overlay
1131// state, and retain the routingMu -> sess.mu order used by attachment paths.
1132func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice protocol.ClientNotice) {
1133 if sess == nil || ac == nil {
1134 return
1135 }
1136 tr := ac.transport()
1137 token := sess.captureAttachmentCapability(ac, tr)
1138 if token.lease == nil {
1139 // Direct test/headless callers retain pointer-based routing. Production
1140 // client frames always carry the exact lease through the attachment path below.
1141 d.handleClientNoticeDirect(sess, ac, notice)
1142 return
1143 }
1144 effect, admitted := ac.beginAttachmentEffect(token)
1145 if !admitted {
1146 return
1147 }
1148 defer effect.End()
1149 d.handleClientNoticeForAttachment(effect, notice)
1150}
1151
1152func (d *Daemon) handleClientNoticeForAttachment(effect *attachmentEffect, notice protocol.ClientNotice) {
1153 d.notices.routingMu.Lock()
1154 if !effect.current() {
1155 d.notices.routingMu.Unlock()
1156 return
1157 }
1158 if d.notices.beforeClientNoticeMutation != nil {
1159 d.notices.beforeClientNoticeMutation()
1160 }
1161 if !effect.current() {
1162 d.notices.routingMu.Unlock()
1163 return
1164 }
1165 if effect.sess == nil {
1166 d.notices.routingMu.Unlock()
1167 return
1168 }
1169 repaint := d.mutateClientNotice(effect.sess, effect.ac, notice)
1170 d.notices.routingMu.Unlock()
1171 if repaint && effect.current() {
1172 d.repaintForNotice(effect.ac)
1173 }
1174}
1175
1176func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice protocol.ClientNotice) {
1177 d.notices.routingMu.Lock()
1178 sess.mu.Lock()
1179 _, current := sess.attachments[ac]
1180 sess.mu.Unlock()
1181 if !current {
1182 d.notices.routingMu.Unlock()
1183 return
1184 }
1185 if d.notices.beforeClientNoticeMutation != nil {
1186 d.notices.beforeClientNoticeMutation()
1187 }
1188 repaint := d.mutateClientNotice(sess, ac, notice)
1189 d.notices.routingMu.Unlock()
1190 if repaint {
1191 d.repaintForNotice(ac)
1192 }
1193}
1194
1195func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice protocol.ClientNotice) bool {
1196 switch notice.Action {
1197 case protocol.ClientNoticeClipboardFallback:
1198 return d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V")
1199 case protocol.ClientNoticeClipboardTooLarge:
1200 return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste")
1201 case protocol.ClientNoticeLinkDegraded:
1202 return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded")
1203 case protocol.ClientNoticeLinkConnected:
1204 return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, sess.id)
1205 default:
1206 return false
1207 }
1208}
1209
1210// recordClientNotice mutates notice history and the selected attachment while
1211// routingMu keeps another connection from changing that selection. Rendering is left
1212// to the caller after it releases routingMu.
1213func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {
1214 n := d.notices.record(domain.Notification{
1215 Code: code,
1216 Severity: sev,
1217 Message: message,
1218 Time: d.clock.Now(),
1219 SessionID: sess.id,
1220 })
1221 return d.publishToast(ac, n)
1222}
1223
1224// resizeForFirstPaint retains attach's synchronous geometry guarantee. The
1225// returned value reports whether the synchronous request was accepted.
1226func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
1227 return sess.geometry.requestResize(d, sess, ac, sz, true)
1228}