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 pendingRouteIdentity bool
103 routeAttentionSubscription ports.RouteAttentionSubscription
104 // routeCreatedSession marks a session created by this attachment's route.
105 // A handshake that never commits Welcome must tear down that exact empty
106 // session, while an attachment routed to an existing session must not.
107 routeCreatedSession bool
108 // routeSessionPurge distinguishes a brand-new session from a restored
109 // stopped session when failed-handshake cleanup removes the live instance.
110 routeSessionPurge bool
111 // beforeAttachmentTokenValidation is a deterministic lifecycle-race seam
112 // used only by package tests.
113 beforeAttachmentTokenValidation func()
114}
115
116type cursorOut struct {
117 valid bool
118 hidden bool
119 row int
120 col int
121 style int
122 hasStyle bool
123}
124
125// pendingByteTimer is retained for overlay input timers. Resize timing belongs
126// exclusively to renderCoordinator.
127type pendingByteTimer struct {
128 timer ports.Timer
129 done chan struct{}
130}
131
132func (p *pendingByteTimer) retain(clock ports.Clock, delay time.Duration, onFire func(ports.Timer)) {
133 p.timer = clock.NewTimer(delay)
134 p.done = make(chan struct{})
135 go func(timer ports.Timer, done <-chan struct{}) {
136 select {
137 case <-timer.C():
138 case <-done:
139 return
140 }
141 onFire(timer)
142 }(p.timer, p.done)
143}
144
145func (p *pendingByteTimer) stop() {
146 if p.timer != nil {
147 p.timer.Stop()
148 p.timer = nil
149 }
150 if p.done != nil {
151 close(p.done)
152 p.done = nil
153 }
154}
155
156func (ac *attachedClient) initOverlays() {
157 ac.overlayOnce.Do(func() { ac.overlays = newOverlayRuntime(ac) })
158}
159
160func (ac *attachedClient) sizeSnapshot() domain.Size {
161 if ac == nil {
162 return domain.Size{}
163 }
164 ac.sizeMu.RLock()
165 defer ac.sizeMu.RUnlock()
166 return ac.size
167}
168
169func (ac *attachedClient) setSize(size domain.Size) {
170 if ac == nil {
171 return
172 }
173 ac.sizeMu.Lock()
174 ac.size = size
175 ac.sizeMu.Unlock()
176}
177
178func (ac *attachedClient) geometryClaimSizeSnapshot() domain.Size {
179 if ac == nil {
180 return domain.Size{}
181 }
182 ac.sizeMu.RLock()
183 claimSize, localSize := ac.geometryClaimSize, ac.size
184 initialized := ac.geometryClaimInitialized
185 ac.sizeMu.RUnlock()
186 if !initialized {
187 return localSize
188 }
189 return claimSize
190}
191
192// publishGeometryClaim makes the claim size and sequence visible as one
193// attachment-local publication under sizeMu.
194func (ac *attachedClient) publishGeometryClaim(size domain.Size, claim uint64) {
195 if ac == nil {
196 return
197 }
198 ac.sizeMu.Lock()
199 ac.geometryClaimSize = size
200 ac.geometryClaimInitialized = true
201 ac.geometryClaim.Store(claim)
202 ac.sizeMu.Unlock()
203}
204
205func (ac *attachedClient) clearGeometryClaim() {
206 if ac == nil {
207 return
208 }
209 ac.sizeMu.Lock()
210 ac.geometryClaimSize = domain.Size{}
211 ac.geometryClaimInitialized = true
212 ac.geometryClaim.Store(0)
213 ac.sizeMu.Unlock()
214}
215
216// currentSession returns the attachment's owning local session.
217func (ac *attachedClient) currentSession() *session {
218 if ac == nil {
219 return nil
220 }
221 return ac.sess.Get()
222}
223
224// currentAttachmentSession is the explicit ownership-boundary alias used by
225// transition and rendering paths.
226func (ac *attachedClient) currentAttachmentSession() *session {
227 return ac.currentSession()
228}
229
230func (ac *attachedClient) setSession(sess *session) { ac.sess.Set(sess) }
231
232// pruneCaptureFrames releases snapshots for panes that have left their owner.
233// Callers must not hold daemon, session, tab, or pane locks.
234func (ac *attachedClient) pruneCaptureFrames(panes ...*pane) {
235 if ac == nil || len(panes) == 0 {
236 return
237 }
238 ac.sendMu.Lock()
239 for _, p := range panes {
240 delete(ac.captureFrames, p)
241 }
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.captureFrames = nil
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: fallbackChromeStyles}
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.Transport {
295 ac.linkMu.Lock()
296 defer ac.linkMu.Unlock()
297 return ac.tr
298}
299
300func (ac *attachedClient) replaceTransport(tr ports.Transport) {
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.Transport) ports.Transport {
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.Transport) error {
340 if tr == nil {
341 return nil
342 }
343 return tr.Close()
344}
345
346func (ac *attachedClient) transportIs(tr ports.Transport) bool {
347 ac.linkMu.Lock()
348 defer ac.linkMu.Unlock()
349 return ac.tr == tr
350}
351
352func (ac *attachedClient) currentTransportIs(tr ports.Transport) 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 ports.RecentRouteSnapshot) bool {
359 snapshot.Entries = append([]ports.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() ports.RecentRouteSnapshot {
384 ac.routeMu.RLock()
385 defer ac.routeMu.RUnlock()
386 snapshot := ac.routeSnapshot
387 snapshot.Entries = append([]ports.RecentRouteEntry(nil), snapshot.Entries...)
388 return snapshot
389}
390
391func (ac *attachedClient) setRouteAttentionSubscription(subscription ports.RouteAttentionSubscription) {
392 subscription.Targets = append([]ports.RouteAttentionTarget(nil), subscription.Targets...)
393 ac.routeMu.Lock()
394 ac.routeAttentionSubscription = subscription
395 ac.routeMu.Unlock()
396}
397
398func (ac *attachedClient) routeAttentionTarget(ref ports.RouteRef) (ports.ExactSessionTarget, bool) {
399 ac.routeMu.RLock()
400 defer ac.routeMu.RUnlock()
401 for _, target := range ac.routeAttentionSubscription.Targets {
402 if target.Ref == ref {
403 return target.Target, true
404 }
405 }
406 return ports.ExactSessionTarget{}, false
407}
408
409func (ac *attachedClient) ackOutputState(epoch, state uint64) {
410 ac.sendMu.Lock()
411 defer ac.sendMu.Unlock()
412 if ac.output != nil {
413 ac.output.ack(epoch, state)
414 }
415}
416
417// rebaseOutput resets only this attachment's output representations. Callers
418// hold sendMu (or the activation barrier).
419func (ac *attachedClient) rebaseOutput() {
420 if ac == nil {
421 return
422 }
423 if ac.output != nil {
424 ac.output.rebase()
425 }
426 ac.lastRoutePosition = ports.RoutePosition{}
427}
428
429var errTransportReplaced = errors.New("client transport was replaced")
430
431// sendExpectedTransport writes only when expected is still the attachment's
432// current transport incarnation. It preserves sendMu -> linkMu lock ordering.
433func (ac *attachedClient) sendExpectedTransport(expected transportSnapshot, f ports.Frame) error {
434 ac.sendMu.Lock()
435 defer ac.sendMu.Unlock()
436 if !ac.transportSnapshotCurrent(expected) {
437 return errTransportReplaced
438 }
439 if expected.transport == nil {
440 return errors.New("client transport is nil")
441 }
442 return expected.transport.Send(f)
443}
444
445// beginExpectedTransportSendLocked validates that expected is still the
446// attachment's current transport incarnation and admits ticket's interruptible
447// transport send. The caller must already hold ac.sendMu and, on success, owns
448// both the send and its matching ticket.endTransportSend.
449func (ac *attachedClient) beginExpectedTransportSendLocked(expected transportSnapshot, ticket *attachmentEffectTicket) error {
450 if expected.transport == nil || !ac.transportSnapshotCurrent(expected) {
451 return errTransportReplaced
452 }
453 if ticket != nil && (ticket.ended.Load() || !ticket.beginTransportSend(expected)) {
454 return errAttachmentTransition
455 }
456 return nil
457}
458
459func (ac *attachedClient) sendExpectedTransportForAttachment(expected transportSnapshot, f ports.Frame, ticket *attachmentEffectTicket) error {
460 ac.sendMu.Lock()
461 defer ac.sendMu.Unlock()
462 // A attachment-bound send requires a live ticket, and reports every rejection,
463 // including a replaced transport, as a lost attachment transition.
464 if ticket == nil {
465 return errAttachmentTransition
466 }
467 if err := ac.beginExpectedTransportSendLocked(expected, ticket); err != nil {
468 return errAttachmentTransition
469 }
470 err := expected.transport.Send(f)
471 if err != nil {
472 ticket.reportTransportFailure(expected)
473 }
474 ticket.endTransportSend()
475 return err
476}
477
478// boundedSend sends f to ac with a deadline watchdog: if the send (including
479// waiting on sendMu behind a wedged paint) does not complete within
480// detachNotifyTimeout, the transport is force-closed, failing the in-flight
481// write. Detach/kill/shutdown paths use this so they are never gated on a
482// client that has stopped draining its socket.
483func (d *Daemon) boundedSend(ac *attachedClient, f ports.Frame) {
484 _ = d.boundedSendErr(ac, f)
485}
486
487func (d *Daemon) boundedSendErr(ac *attachedClient, f ports.Frame) error {
488 expected := ac.transportSnapshot()
489 if expected.transport == nil {
490 return errors.New("client transport is nil")
491 }
492 tr, err := d.boundedSendWith(expected.transport, func() error {
493 return ac.sendExpectedTransport(expected, f)
494 })
495 if err != nil && errors.Is(err, errSendTimedOut) {
496 _ = ac.closeCapturedTransport(tr)
497 }
498 return err
499}
500
501func (d *Daemon) boundedSendOutputErr(ac *attachedClient, b []byte) error {
502 _, err := d.boundedSendOutputErrTransport(ac, b)
503 return err
504}
505
506func (d *Daemon) boundedSendOutputErrTransport(ac *attachedClient, b []byte) (ports.Transport, error) {
507 expected := ac.transportSnapshot()
508 if expected.transport == nil {
509 return nil, errors.New("client transport is nil")
510 }
511 if owned, ok := expected.transport.(ports.OwnedSynchronousTransport); ok {
512 ac.sendMu.Lock()
513 defer ac.sendMu.Unlock()
514 if !ac.transportSnapshotCurrent(expected) {
515 return expected.transport, errTransportReplaced
516 }
517 ac.output.lockView()
518 defer ac.output.unlockView()
519 frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
520 if err == nil {
521 err = owned.SendSynchronous(frame)
522 }
523 return expected.transport, err
524 }
525 return d.boundedSendWith(expected.transport, func() error {
526 ac.sendMu.Lock()
527 defer ac.sendMu.Unlock()
528 if !ac.transportSnapshotCurrent(expected) {
529 return errTransportReplaced
530 }
531 ac.output.lockView()
532 defer ac.output.unlockView()
533 frame, err := ac.output.sideEffectLocked(b, ac.echoAck.Load())
534 if err != nil {
535 return err
536 }
537 return expected.transport.Send(frame)
538 })
539}
540
541var errSendTimedOut = errors.New("send timed out")
542
543func (d *Daemon) boundedSendWith(tr ports.Transport, send func() error) (ports.Transport, error) {
544 return d.boundedSendWithTimeout(detachNotifyTimeout, tr, send)
545}
546
547func (d *Daemon) boundedSendWithTimeout(timeout time.Duration, tr ports.Transport, send func() error) (ports.Transport, error) {
548 timer := d.clock.NewTimer(timeout)
549 result := make(chan error, 1)
550 go func() {
551 result <- send()
552 }()
553 select {
554 case err := <-result:
555 timer.Stop()
556 return tr, err
557 case <-timer.C():
558 select {
559 case err := <-result:
560 return tr, err
561 default:
562 }
563 d.log.Warn("bounded send timed out; force closing client transport")
564 return tr, errSendTimedOut
565 }
566}
567
568type detachedAttachmentSnapshot struct {
569 ac *attachedClient
570 transport transportSnapshot
571}
572
573// notifyDetachedSnapshotAsync sends and closes only the transport incarnation
574// captured by atomic session teardown. It cannot touch a later link installed
575// on the same attachedClient object.
576func (d *Daemon) notifyDetachedSnapshotAsync(snapshot detachedAttachmentSnapshot, reason uint8) {
577 if snapshot.ac == nil || snapshot.transport.transport == nil {
578 return
579 }
580 done := make(chan struct{})
581 d.mu.Lock()
582 // Prune completed entries so the slice stays bounded by the number of
583 // notifications actually in flight.
584 kept := d.notifies[:0]
585 for _, c := range d.notifies {
586 select {
587 case <-c:
588 default:
589 kept = append(kept, c)
590 }
591 }
592 d.notifies = append(kept, done)
593 d.mu.Unlock()
594
595 go func() {
596 defer close(done)
597 _, _ = d.boundedSendWith(snapshot.transport.transport, func() error {
598 return snapshot.ac.sendExpectedTransport(snapshot.transport, frameDetached(reason))
599 })
600 revoked := snapshot.ac.revokeTransport(snapshot.transport.transport)
601 if revoked == nil {
602 revoked = snapshot.transport.transport
603 }
604 _ = snapshot.ac.closeCapturedTransport(revoked)
605 d.log.Info("client detached", "reason", reason)
606 }()
607}
608
609// waitNotifies blocks until every Detached notification in flight at the time
610// of the call has completed. Each is deadline-bounded (boundedSend), so this
611// wait is bounded too.
612func (d *Daemon) waitNotifies() {
613 for _, c := range d.notifiesSnapshot() {
614 <-c
615 }
616}
617
618func (d *Daemon) notifiesSnapshot() []chan struct{} {
619 d.mu.Lock()
620 defer d.mu.Unlock()
621 snapshot := make([]chan struct{}, len(d.notifies))
622 copy(snapshot, d.notifies)
623 return snapshot
624}
625
626type attachClientOptions struct {
627 clientID [16]byte
628 resumeCapable bool
629 maxOutputInFlight uint8
630 remoteOrigin string
631 navigationCapabilities ports.NavigationCapabilities
632 startupOverlay ports.StartupOverlay
633}
634
635func (d *Daemon) attachClient(sess *session, tr ports.Transport, sz domain.Size, opts attachClientOptions) (*attachedClient, error) {
636 d.mu.Lock()
637 ac := d.prepareAttachedClientLocked(tr, sz, opts)
638 d.mu.Unlock()
639 result, err := d.transitionAttachment(attachmentTransitionRequest{
640 target: sess,
641 next: ac,
642
643 expectedTransport: ac.transportSnapshot(),
644 ready: false,
645 })
646 if err != nil {
647 if tr != nil {
648 _ = tr.Close()
649 }
650 return nil, err
651 }
652 d.finishAttachedClient(sess, ac, opts)
653 d.deferAttachmentTransitionCleanups(result)
654 return ac, nil
655}
656
657func (d *Daemon) finishAttachedClient(sess *session, ac *attachedClient, opts attachClientOptions) {
658 d.touchMRU(sess)
659 d.log.Info("client attached", "session", sess.name, "resume", opts.resumeCapable)
660 d.applyHostTheme(sess, ac, themeui.Theme{}, true)
661}
662
663// prepareAttachedClientLocked allocates one detached attachment. Caller holds
664// d.mu only to allocate its resume token; attachment publication happens later after
665// the caller releases every architecture lock.
666func (d *Daemon) prepareAttachedClientLocked(tr ports.Transport, sz domain.Size, opts attachClientOptions) *attachedClient {
667 resumeToken := uint64(0)
668 if opts.resumeCapable {
669 resumeToken = d.nextResumeTokenLocked()
670 }
671 output := newOutputStateStream(opts.maxOutputInFlight)
672 ac := &attachedClient{
673 tr: tr,
674 output: output,
675 size: sz,
676 view: attachmentView{windowRows: sz.Rows, windowSet: true},
677 clientID: opts.clientID,
678 remoteOrigin: opts.remoteOrigin,
679 navigationCapabilities: opts.navigationCapabilities,
680 startupOverlay: opts.startupOverlay,
681 resumeCapable: opts.resumeCapable,
682 resumeToken: resumeToken,
683 }
684 output.attachment = ac
685 ac.initOverlays()
686 ac.keys = keys.NewRouter(d.clock, daemonKeyHandler{d: d, ac: ac}, &d.bindings)
687 return ac
688}
689
690// attachCoordinator installs the coordinator lease for an attachment. Session
691// membership remains independent, so installing one attachment never displaces
692// another.
693func (d *Daemon) attachCoordinator(sess *session, old, current *attachedClient, ready bool) *renderCoordinator {
694 rc, cleanup := d.attachCoordinatorDeferred(sess, old, current, ready)
695 cleanup.finish()
696 return rc
697}
698
699func (d *Daemon) attachCoordinatorDeferred(sess *session, _ *attachedClient, current *attachedClient, ready bool) (*renderCoordinator, renderLifecycleCleanup) {
700 rc := d.ensureRenderCoordinator(sess)
701 if current != nil {
702 rc.attachWithReadiness(current, ready)
703 }
704 return rc, renderLifecycleCleanup{}
705}
706
707// ensureRenderCoordinator publishes the session's single coordinator without
708// binding an attachment lease. Centralized attachment transitions bind the
709// lease later while their ordered session locks are still held.
710func (d *Daemon) ensureRenderCoordinator(sess *session) *renderCoordinator {
711 sess.mu.Lock()
712 defer sess.mu.Unlock()
713 return d.ensureRenderCoordinatorPrelocked(sess)
714}
715
716// ensureRenderCoordinatorPrelocked is the non-reentrant coordinator setup seam
717// for transactions that already hold sess.mu.
718func (d *Daemon) ensureRenderCoordinatorPrelocked(sess *session) *renderCoordinator {
719 return d.ensureAttachmentRenderCoordinatorPrelocked(sess)
720}
721
722// ensureAttachmentRenderCoordinatorPrelocked publishes the coordinator for an
723// exact session. The caller holds entry.core().mu.
724func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *renderCoordinator {
725 if rc := attachmentRenderCoordinator(entry); rc != nil {
726 return rc
727 }
728 var rc *renderCoordinator
729 rc = newRenderCoordinator(renderCoordinatorOptions{
730 clock: d.clock,
731 observer: d.runtimeObserver,
732 wake: func(w renderWake) {
733 if !rc.wakeCurrent(w) {
734 return
735 }
736 // The session snapshot is deterministic. Each paint revalidates the
737 // attachment's membership and transport fence after that snapshot, so
738 // a detached peer cannot stop remaining attachments receiving output.
739 attachments := entry.snapshotAttachments()
740 for _, attachment := range attachments {
741 if !rc.wakeCurrent(w) {
742 return
743 }
744 lease := w.attachmentLeases[attachment]
745 if lease == nil || !rc.leaseCurrent(lease, true) {
746 continue
747 }
748 // Pane damage is session-shared; every fan-out attachment needs a
749 // fresh capture because any peer may acknowledge the damage first.
750 // Each attachment owns its send lock, so a blocked peer cannot stall
751 // this fan-out or the PTY reader. A handshake-targeted wake is the
752 // one synchronous exception: its first paint must finish before the
753 // handshake deadline is released.
754 fanoutReset := w.reset || len(attachments) > 1
755 paint := func() {
756 if d.paint(entry, attachment, fanoutReset, lease) == paintBlockedCapacity {
757 rc.retryCapacity(w, lease)
758 }
759 }
760 if w.lease != nil && w.lease.attachment == attachment {
761 paint()
762 } else if len(attachments) > 1 {
763 go paint()
764 } else {
765 paint()
766 }
767 }
768 },
769 ackReadyFor: func(attached *attachedClient) bool {
770 // outputStateStream publishes capacity atomically. Do not take
771 // attached.sendMu here: a slow transport may be holding it for an
772 // in-flight Send, and that peer must not gate healthy attachments.
773 return attached == nil || attached.output == nil || !attached.output.atCapacity()
774 },
775 })
776 installAttachmentRenderCoordinator(entry, rc)
777 return rc
778}
779
780// resetScreenDefaultColors clears the known default foreground/background
781// colors on every tab in sess. Called once a client has been detached, this
782// makes child OSC 10/11 queries go back to being swallowed (Known=false)
783// instead of being answered with the departed client's colors, which the
784// next client (with a different terminal theme) may never have reported.
785// Mirrors attachClient's reset loop: snapshot sess.tabs under sess.mu,
786// release it, then take each tb.mu in turn — never holding sess.mu and
787// tb.mu together. Guarded against a race with a newer attachment: if the
788// session has gained an attachment by the time sess.mu is taken, that
789// attachment has already run its attach-time reset, so this call must leave
790// the tabs alone rather than clobbering freshly applied colors.
791func (d *Daemon) resetScreenDefaultColors(sess *session) {
792 d.applyHostTheme(sess, nil, themeui.Theme{}, true)
793}
794
795// firstPaintForTransition is the only active post-transition paint entry
796// point. It rejects a stale attachment capability before rebasing output and carries
797// the exact coordinator lease through every resize and render effect.
798func (d *Daemon) firstPaintForTransition(token attachmentConnectionToken) bool {
799 if !token.attachmentCurrent() {
800 return false
801 }
802 ticket, admitted := token.ac.beginAttachmentEffect(token)
803 if !admitted {
804 return false
805 }
806 token.effect = ticket
807 defer token.endAttachmentEffect()
808 if d.afterAttachmentEffectAdmitted != nil {
809 d.afterAttachmentEffectAdmitted(token)
810 }
811 if token.rebase {
812 if d.beforeFirstPaintSendWait != nil {
813 d.beforeFirstPaintSendWait(token)
814 }
815 token.ac.sendMu.Lock()
816 if token.ac.renderStages.handoffRebase != nil {
817 token.ac.renderStages.handoffRebase()
818 }
819 token.ac.rebaseOutput()
820 // Captures belong to the old session even when pane-local IDs happen
821 // to be reused by the destination.
822 token.ac.captureFrames = nil
823 token.ac.sendMu.Unlock()
824 }
825 if token.sess == nil {
826 return false
827 }
828 d.firstPaintWithLease(token.sess, token.ac, token.lease, true)
829 return true
830}
831
832// firstPaint guarantees the freshly attached client sees the full screen. The
833// shared session geometry is reconciled to the latest attachment claim before
834// each attachment gets its own outer-window paint.
835func (d *Daemon) firstPaint(sess *session, ac *attachedClient) {
836 d.firstPaintWithLease(sess, ac, nil, false)
837}
838
839func (d *Daemon) firstPaintWithLease(sess *session, ac *attachedClient, lease *attachmentLease, asyncGeometry bool) {
840 // Global notices raised while nothing was attached surface on this client.
841 // Drained before the early return below so a session without an active tab
842 // cannot swallow the queue.
843 d.drainPendingForFirstPaint(sess, ac)
844 tb := sess.tabForAttachment(ac)
845 if tb == nil {
846 return
847 }
848 if asyncGeometry {
849 // PTY.Resize is an external, non-cancelable operation. Do not hold the
850 // handshake on it; the first frame can use retained geometry and the
851 // shared invalidation will repaint every attachment after the claim wins.
852 d.recalculateSessionGeometryAndInvalidateAsync(sess, "client.go")
853 } else {
854 d.recalculateSessionGeometryAndInvalidate(sess, nil, "client.go")
855 }
856 d.refreshBarScriptsIfDue(sess, d.clock.Now(), true)
857 // Floating activation may perform its own pane-local warmup after the shared
858 // session geometry has been reconciled above.
859 d.ensureFloatingWarm(sess, tb)
860 if lease == nil {
861 d.invalidateRenderNow(sess, ac, true, "client.go")
862 } else if rc := sess.renderCoordinator(); rc != nil && rc.invalidateForLease(ac, lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"}) {
863 rc.fireCurrentForLease(lease)
864 }
865}
866
867// detachIfCurrent clears the client iff ac is the current one, reporting
868
869func themeFromMessage(msg ports.Theme) themeui.Theme {
870 return themeui.Theme{
871 Foreground: msg.Foreground,
872 Background: msg.Background,
873 Palette: msg.Palette,
874 PaletteKnown: msg.PaletteKnown,
875 HasFG: msg.HasForeground,
876 HasBG: msg.HasBackground,
877 TrueColor: msg.TrueColor,
878 Known: msg.HasForeground && msg.HasBackground,
879 SchemeKnown: msg.SchemeKnown,
880 Light: msg.Light,
881 }
882}
883
884func (d *Daemon) applyTheme(sess *session, ac *attachedClient, msg ports.Theme) {
885 clientTheme := themeFromMessage(msg)
886 ac.setClientTheme(clientTheme)
887
888 if !d.applyHostTheme(sess, ac, clientTheme, false) {
889 return
890 }
891 d.invalidateRender(sess, ac, true, "client.go")
892}
893
894func (d *Daemon) applyThemeForAttachment(token attachmentConnectionToken, msg ports.Theme) {
895 if !token.attachmentEffectCurrent() {
896 return
897 }
898 clientTheme := themeFromMessage(msg)
899 token.ac.setClientTheme(clientTheme)
900 sess := token.sess
901 if sess == nil || !token.attachmentEffectCurrent() || !d.applyHostTheme(sess, token.ac, clientTheme, false) {
902 return
903 }
904 if !token.attachmentEffectCurrent() {
905 return
906 }
907 rc := sess.renderCoordinator()
908 if token.lease == nil || rc == nil {
909 d.invalidateRender(sess, token.ac, true, "client.go")
910 return
911 }
912 rc.invalidateForLease(token.ac, token.lease, renderInvalidation{class: invalidateUrgent, reset: true, producer: "client.go"})
913}
914
915func (d *Daemon) resize(sess *session, ac *attachedClient, sz domain.Size) {
916 d.requestTransactionalResize(sess, ac, sz, false)
917}
918
919// resizeAttachmentForLease updates the sender's window/view, then makes that
920// attachment the latest shared tab/PTY geometry claimant. Callers hold the
921// attachment effect admission; output state is rebased before any redraw so
922// every subsequent frame starts a new epoch.
923func (d *Daemon) resizeAttachmentForLease(token attachmentConnectionToken, size domain.Size) bool {
924 if token.ac == nil || !size.Valid() || !token.attachmentEffectCurrent() {
925 return false
926 }
927 ac := token.ac
928 ac.sendMu.Lock()
929 if !token.attachmentEffectCurrent() || !ac.transportSnapshotCurrent(token.transport) {
930 ac.sendMu.Unlock()
931 return false
932 }
933 sameSize := ac.sizeSnapshot() == size
934 if !sameSize {
935 ac.setSize(size)
936 view := ac.viewSnapshot()
937 view.windowRows = size.Rows
938 view.windowSet = true
939 view.windowTop = 0
940 view.revision++
941 ac.publishView(view)
942 ac.rebaseOutput()
943 ac.pipelineCache = composeCacheInput{}
944 ac.pipelineScratch = composeCacheInput{}
945 }
946 ac.sendMu.Unlock()
947
948 if !sameSize && ac.overlays != nil && ac.overlays.pickerActive() {
949 // The picker preview geometry is attachment-local. A resize must
950 // invalidate any in-flight remote request and fetch the selected row
951 // at the new bounded preview dimensions.
952 d.registerPreviewForSelection(ac)
953 }
954
955 sess := token.sess
956 if sess == nil {
957 return false
958 }
959 // Shared PTY/layout geometry follows the latest accepted attachment claim.
960 // When this resize changes canonical geometry, the shared invalidation fans
961 // out a fresh frame to every attachment; otherwise only this attachment
962 // needs a viewport redraw.
963 if d.recalculateSessionGeometryAndInvalidate(sess, ac, "client.go") {
964 return true
965 }
966 if sameSize {
967 return false
968 }
969 rc := sess.renderCoordinator()
970 if rc == nil {
971 return false
972 }
973 // Rendering is attachment-scoped. Schedule it through the coordinator so
974 // rapid resize frames coalesce under the attachment lease and deadline.
975 return rc.scheduleResizeForLease(size, ac, token.lease, func(uint64) {
976 go d.paint(sess, ac, true, token.lease)
977 }) != 0
978}
979
980// handleClientNotice maps the closed client-event enum to daemon-owned notice
981// content. routingMu makes ownership validation and toast mutation one atomic
982// attachment-routing operation: attachment publication also takes routingMu
983// before changing membership. Never retain sess.mu while touching notice or overlay
984// state, and retain the routingMu -> sess.mu order used by attachment paths.
985func (d *Daemon) handleClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) {
986 if sess == nil || ac == nil {
987 return
988 }
989 tr := ac.transport()
990 token := sess.attachmentToken(ac, tr)
991 if token.lease == nil {
992 // Direct test/headless callers retain pointer-based routing. Production
993 // client frames always carry the exact lease through the attachment path below.
994 d.handleClientNoticeDirect(sess, ac, notice)
995 return
996 }
997 d.handleClientNoticeForAttachment(token, notice)
998}
999
1000func (d *Daemon) handleClientNoticeForAttachment(token attachmentConnectionToken, notice ports.ClientNotice) {
1001 d.notices.routingMu.Lock()
1002 if !token.attachmentEffectCurrent() {
1003 d.notices.routingMu.Unlock()
1004 return
1005 }
1006 if d.notices.beforeClientNoticeMutation != nil {
1007 d.notices.beforeClientNoticeMutation()
1008 }
1009 if !token.attachmentEffectCurrent() {
1010 d.notices.routingMu.Unlock()
1011 return
1012 }
1013 if token.sess == nil {
1014 d.notices.routingMu.Unlock()
1015 return
1016 }
1017 repaint := d.mutateClientNotice(token.sess, token.ac, notice)
1018 d.notices.routingMu.Unlock()
1019 if repaint && token.attachmentEffectCurrent() {
1020 d.repaintForNotice(token.ac)
1021 }
1022}
1023
1024func (d *Daemon) handleClientNoticeDirect(sess *session, ac *attachedClient, notice ports.ClientNotice) {
1025 d.notices.routingMu.Lock()
1026 sess.mu.Lock()
1027 _, current := sess.attachments[ac]
1028 sess.mu.Unlock()
1029 if !current {
1030 d.notices.routingMu.Unlock()
1031 return
1032 }
1033 if d.notices.beforeClientNoticeMutation != nil {
1034 d.notices.beforeClientNoticeMutation()
1035 }
1036 repaint := d.mutateClientNotice(sess, ac, notice)
1037 d.notices.routingMu.Unlock()
1038 if repaint {
1039 d.repaintForNotice(ac)
1040 }
1041}
1042
1043func (d *Daemon) mutateClientNotice(sess *session, ac *attachedClient, notice ports.ClientNotice) bool {
1044 switch notice.Action {
1045 case ports.ClientNoticeClipboardFallback:
1046 return d.recordClientNotice(sess, ac, domain.NoticeError, domain.NoticeClipboard, "image paste failed; sent Ctrl+V")
1047 case ports.ClientNoticeClipboardTooLarge:
1048 return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeClipboardTooLarge, "image too large to paste")
1049 case ports.ClientNoticeLinkDegraded:
1050 return d.recordClientNotice(sess, ac, domain.NoticeWarn, domain.NoticeConnection, "connection degraded")
1051 case ports.ClientNoticeLinkConnected:
1052 return d.dismissToastWithoutRepaint(ac, domain.NoticeConnection, sess.id)
1053 default:
1054 return false
1055 }
1056}
1057
1058// recordClientNotice mutates notice history and the selected attachment while
1059// routingMu keeps another connection from changing that selection. Rendering is left
1060// to the caller after it releases routingMu.
1061func (d *Daemon) recordClientNotice(sess *session, ac *attachedClient, sev domain.NoticeSeverity, code domain.NoticeCode, message string) bool {
1062 n := d.notices.record(domain.Notification{
1063 Code: code,
1064 Severity: sev,
1065 Message: message,
1066 Time: d.clock.Now(),
1067 SessionID: sess.id,
1068 })
1069 return d.publishToast(ac, n)
1070}
1071
1072// resizeForFirstPaint retains attach's synchronous geometry guarantee. The
1073// returned value reports whether the synchronous request was accepted.
1074func (d *Daemon) resizeForFirstPaint(sess *session, ac *attachedClient, sz domain.Size) bool {
1075 return d.requestTransactionalResize(sess, ac, sz, true)
1076}
1077
1078// killSession removes a session and tears down its resources. It is
1079// idempotent: only the caller that wins the registry delete acts. When the
1080// registry empties it marks the daemon closing (atomically with the
1081// empty-check, under d.mu) and signals shutdown.
1082//
1083// Teardown ordering matters: context cancel, pty.Close, and the done signal
1084// run first and unconditionally — never gated behind a client send. The