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