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

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