v0.2.0 80c61e3

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