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