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