v0.7.0 4c16dd8

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

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