v0.3.0 99dc266

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

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