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