client.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close