attachment_output.go RawHistoryFolder

1package daemon
2
3import (
4	"errors"
5	"strconv"
6	"sync"
7	"sync/atomic"
8
9	renderer "github.com/bnema/vev-vt/ansi"
10	"github.com/bnema/vev/internal/domain"
11	"github.com/bnema/vev/internal/domain/terminalcap"
12	"github.com/bnema/vev/internal/protocol"
13	"github.com/bnema/vev/internal/usecase/colorprofile"
14)
15
16// attachmentOutput owns the terminal-output dependency chain and emitted
17// render state for one attached client. Each incremental output is rendered
18// from the preceding emitted frame and remains pipelineable without waiting for an
19// ACK. Cumulative ACKs advance the bounded paint window.
20// A reset frame starts a new epoch, clears the dependency chain, and advertises
21// state 0 as its base.
22//
23// Callers serialize access with attachedClient.sendMu. A prepared transaction
24// must be sent or committed as a no-op before the next transaction is prepared.
25type attachmentOutput struct {
26	renderer                  *renderer.Renderer
27	epoch                     uint64
28	next                      uint64
29	acked                     uint64
30	generation                uint64
31	maxOutstanding            uint64
32	outstandingAtomic         atomic.Uint64
33	maxOutstandingAtomic      atomic.Uint64
34	forceSnapshot             bool
35	initialized               bool
36	viewPublication           uint64
37	lastViewContext           protocol.ViewContext
38	lastCursor                cursorOut
39	lastRoutePosition         protocol.RoutePosition
40	graphicsOutput            *graphicsOutputState
41	graphicsUnsupportedWarned atomic.Bool
42	attachment                *attachedClient
43	stateMu                   sync.Mutex
44}
45
46// reconfigureAttachmentOutput applies a replacement terminal's declared
47// capabilities to attachment-owned output state. Caller holds d.mu.
48func (d *Daemon) reconfigureAttachmentOutput(sess *session, ac *attachedClient, h protocol.Hello) {
49	if d == nil || ac == nil || ac.output == nil {
50		return
51	}
52	ac.terminalCapabilities.KittyGraphics = h.KittyDirectGraphics
53	// The replacement terminal's color claim wins over whatever the previous
54	// link negotiated: its renderer must encode for the terminal now attached.
55	// Only the mode changes the encoding; the source is stored but never
56	// forces a renderer swap.
57	color := terminalcap.Resolve(h.Env, h.Color).Color
58	swap := color.Mode != ac.terminalCapabilities.Color.Mode
59	ac.terminalCapabilities.Color = color
60	if swap {
61		ac.output.setColorProfile(colorprofile.Profile(color))
62	}
63	// A replacement terminal declares its own keyboard protocol.
64	if ac.keys != nil {
65		ac.keys.SetKittyKeyboard(h.KittyKeyboard)
66	}
67	ac.output.graphicsUnsupportedWarned.Store(false)
68	// A replacement connection is a different outer terminal. Retire the old
69	// attachment-local state without writing cleanup to the new terminal, then
70	// allocate a fresh namespace for any replay it accepts.
71	d.discardAttachmentOutputLocked(ac)
72	if !h.KittyDirectGraphics {
73		// The parked transport has already been retired. Do not emit cleanup into
74		// a replacement connection that did not declare graphics support.
75		return
76	}
77	if namespace, fence := d.reserveGraphicsNamespaceLeaseLocked(graphicsNamespaceKey(sess, ac.clientID)); namespace != 0 {
78		ac.output.graphicsOutput = newGraphicsOutputStateWithLease(namespace, fence)
79		return
80	}
81	// Namespace exhaustion fails closed; ANSI output remains available.
82	ac.terminalCapabilities.KittyGraphics = false
83}
84
85// setColorProfile swaps in a renderer for profile. The next frame is a full
86// repaint (rebaseLocked forces a snapshot), so no emitted-state diff crosses
87// the profile change.
88func (s *attachmentOutput) setColorProfile(profile renderer.ColorProfile) {
89	if s == nil {
90		return
91	}
92	s.lockView()
93	defer s.unlockView()
94	s.renderer = renderer.NewWithColorProfile(renderer.Capabilities{}, profile)
95	s.rebaseLocked()
96}
97
98// lockView serializes an attachment's view publication with every output
99// state transition. It deliberately does not take sendMu: callers that hold
100// architecture locks can publish a view without reversing the paint path's
101// sendMu -> session lock order.
102func (s *attachmentOutput) lockView() {
103	if s == nil {
104		return
105	}
106	if s.attachment == nil {
107		s.stateMu.Lock()
108		return
109	}
110	s.attachment.viewMu.Lock()
111}
112
113func (s *attachmentOutput) unlockView() {
114	if s == nil {
115		return
116	}
117	if s.attachment == nil {
118		s.stateMu.Unlock()
119		return
120	}
121	s.attachment.viewMu.Unlock()
122}
123
124func newOutputStateStream(windowSize ...uint8) *attachmentOutput {
125	return newOutputStateStreamForProfile(renderer.ColorProfileTrueColor, windowSize...)
126}
127
128func newOutputStateStreamForCapabilities(capabilities terminalcap.Capabilities, windowSize ...uint8) *attachmentOutput {
129	return newOutputStateStreamForProfile(colorprofile.Profile(capabilities.Color), windowSize...)
130}
131
132func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *attachmentOutput {
133	window := uint8(maxUnackedOutputStates)
134	if len(windowSize) > 0 {
135		window = normalizeOutputWindow(windowSize[0])
136	}
137	stream := &attachmentOutput{
138		renderer:       renderer.NewWithColorProfile(renderer.Capabilities{}, profile),
139		epoch:          1,
140		maxOutstanding: uint64(window),
141	}
142	stream.maxOutstandingAtomic.Store(uint64(window))
143	return stream
144}
145
146type cursorCandidate struct {
147	data []byte
148	next cursorOut
149}
150
151// preparedAttachmentOutput is one speculative terminal-output transaction.
152// ANSI renderer state and cursor state advance together only after the output
153// frame is accepted by the attachment's state stream.
154type preparedAttachmentOutput struct {
155	output   *attachmentOutput
156	ansi     *preparedOutput
157	graphics *preparedGraphicsOutput
158	cursor   cursorCandidate
159	data     []byte
160}
161
162func (o *attachmentOutput) prepareFrame(d *Daemon, state *capturedRenderState, frame renderer.Frame, damage []renderer.Damage, reset bool, desired cursorOut) (*preparedAttachmentOutput, error) {
163	ansi, err := o.prepare(frame, damage, reset)
164	if err != nil {
165		return nil, err
166	}
167	context := state.viewContext()
168	ansi.context = &context
169	ansi.viewRevision = state.view.revision
170	cursor := o.prepareCursorTail(desired, len(ansi.data) > 0)
171	graphicsReset := reset || o.forceSnapshot || !o.initialized
172	graphicsBudget := max(protocol.MaxOutputDataLen-len(ansi.data)-len(cursor.data), 0)
173	preparedGraphics, err := graphicsOutputDataWithDaemonLimit(d, state, o.attachment, graphicsReset, graphicsBudget)
174	if err != nil {
175		return nil, err
176	}
177	data := append([]byte(nil), ansi.data...)
178	if preparedGraphics != nil {
179		data = append(data, preparedGraphics.data...)
180	}
181	data = append(data, cursor.data...)
182	return &preparedAttachmentOutput{output: o, ansi: ansi, graphics: preparedGraphics, cursor: cursor, data: data}, nil
183}
184
185func (p *preparedAttachmentOutput) send(echoAck uint64, send func(protocol.Output) error) error {
186	if p == nil || p.ansi == nil {
187		return nil
188	}
189	sendOutput := send
190	if p.graphics != nil {
191		sendOutput = func(output protocol.Output) error {
192			p.graphics.markSendAttempted()
193			return send(output)
194		}
195	}
196	if err := p.ansi.send(p.data, echoAck, sendOutput); err != nil {
197		p.abortGraphics()
198		return err
199	}
200	p.commitIfSent()
201	return nil
202}
203
204func (p *preparedAttachmentOutput) publishNoBytes(confirm bool, send func(protocol.UIViewUpdate) error) error {
205	if p == nil || p.ansi == nil {
206		return nil
207	}
208	if err := p.ansi.publishNoBytes(confirm, send); err != nil {
209		p.abortGraphics()
210		return err
211	}
212	p.commitIfSent()
213	return nil
214}
215
216func (p *preparedAttachmentOutput) commitNoSend() {
217	if p == nil || p.ansi == nil {
218		return
219	}
220	p.ansi.commitNoSend()
221	p.commitIfSent()
222}
223
224func (p *preparedAttachmentOutput) commitIfSent() {
225	if p == nil || p.ansi == nil || !p.ansi.sent {
226		p.abortGraphics()
227		return
228	}
229	if p.graphics != nil {
230		p.graphics.commit()
231	}
232	if p.output != nil {
233		p.output.lastCursor = p.cursor.next
234	}
235}
236
237func (p *preparedAttachmentOutput) abort() {
238	if p != nil {
239		p.abortGraphics()
240	}
241}
242
243func (p *preparedAttachmentOutput) abortGraphics() {
244	if p != nil && p.graphics != nil {
245		p.graphics.abort()
246	}
247}
248
249func (p *preparedAttachmentOutput) sent() bool {
250	return p != nil && p.ansi != nil && p.ansi.sent
251}
252
253func (o *attachmentOutput) prepareCursorTail(desired cursorOut, force bool) cursorCandidate {
254	candidate := cursorCandidate{next: desired}
255	candidate.next.valid = true
256	prev := o.lastCursor
257	changed := force || !prev.valid || prev.hidden != desired.hidden || prev.row != desired.row || prev.col != desired.col || prev.style != desired.style || prev.hasStyle != desired.hasStyle
258	if !changed {
259		return candidate
260	}
261	if desired.hidden {
262		candidate.data = []byte("\x1b[?25l")
263		return candidate
264	}
265	candidate.data = append(candidate.data, "\x1b["...)
266	candidate.data = strconv.AppendInt(candidate.data, int64(desired.row+1), 10)
267	candidate.data = append(candidate.data, ';')
268	candidate.data = strconv.AppendInt(candidate.data, int64(desired.col+1), 10)
269	candidate.data = append(candidate.data, 'H')
270	if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
271		candidate.data = append(candidate.data, "\x1b["...)
272		candidate.data = strconv.AppendInt(candidate.data, int64(desired.style), 10)
273		candidate.data = append(candidate.data, " q"...)
274	}
275	candidate.data = append(candidate.data, "\x1b[?25h"...)
276	return candidate
277}
278
279type preparedOutput struct {
280	stream               *attachmentOutput
281	draw                 renderer.PreparedDraw
282	base                 uint64
283	next                 uint64
284	epoch                uint64
285	generation           uint64
286	connectionGeneration uint64
287	viewRevision         uint64
288	context              *protocol.ViewContext
289	// boundary is the exact accepted semantic publication, not mutable stream
290	// state that a concurrent view rebase could replace before receipt send.
291	boundary  protocol.UIReceipt
292	size      domain.Size
293	data      []byte
294	reset     bool
295	attempted bool
296	sent      bool
297}
298
299func (s *attachmentOutput) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
300	s.lockView()
301	defer s.unlockView()
302	if s.epoch == 0 {
303		s.epoch = 1
304	}
305	if reset && s.initialized {
306		s.rebaseLocked()
307	}
308	reset = reset || s.forceSnapshot || !s.initialized
309	if s.next == ^uint64(0) {
310		return nil, errors.New("output state number exhausted")
311	}
312	draw, err := s.renderer.Prepare(frame, damage, reset)
313	if err != nil {
314		return nil, err
315	}
316	s.initialized = true
317	generation, epoch, viewRevision := s.fenceLocked()
318	return &preparedOutput{
319		stream:               s,
320		draw:                 draw,
321		base:                 s.next,
322		next:                 s.next + 1,
323		epoch:                epoch,
324		generation:           s.generation,
325		connectionGeneration: generation,
326		viewRevision:         viewRevision,
327		size:                 domain.Size{Cols: frame.Width, Rows: frame.Height},
328		data:                 draw.Bytes(),
329		reset:                reset,
330	}, nil
331}
332
333func (p *preparedOutput) send(data []byte, echoAck uint64, send func(protocol.Output) error) error {
334	if p == nil || p.stream == nil || p.attempted {
335		return nil
336	}
337	p.stream.lockView()
338	defer p.stream.unlockView()
339	p.attempted = true
340	if !p.currentLocked() {
341		return nil
342	}
343	if len(data) == 0 {
344		p.commit()
345		p.sent = true
346		return nil
347	}
348	base := p.base
349	if p.reset {
350		base = 0
351	}
352	var context *protocol.ViewContext
353	if p.context != nil {
354		if p.stream.viewPublication == ^uint64(0) {
355			return errors.New("view publication number exhausted")
356		}
357		candidate := *p.context
358		candidate.Publication = p.stream.viewPublication + 1
359		context = &candidate
360	}
361	out, err := marshalOutputState(data, p.epoch, base, p.next, echoAck, p.viewRevision, p.size, p.reset, context)
362	if err != nil {
363		p.stream.forceSnapshot = true
364		return err
365	}
366	if err := send(out); err != nil {
367		p.stream.forceSnapshot = true
368		return err
369	}
370	p.commit()
371	if context != nil {
372		p.stream.viewPublication = context.Publication
373		p.stream.lastViewContext = *context
374		p.boundary = protocol.UIReceipt{Epoch: p.epoch, State: p.next, ViewPublication: context.Publication, Outcome: protocol.UIReceiptProcessed}
375	}
376	p.stream.next = p.next
377	p.stream.publishOutstanding()
378	p.sent = true
379	return nil
380}
381
382func (p *preparedOutput) commitNoSend() {
383	if p == nil || p.stream == nil || p.attempted {
384		return
385	}
386	p.stream.lockView()
387	defer p.stream.unlockView()
388	p.attempted = true
389	if !p.currentLocked() || len(p.data) != 0 {
390		return
391	}
392	p.commit()
393	p.sent = true
394}
395
396// publishNoBytes commits a composition without advancing the terminal replay
397// chain. A fence can request a fresh semantic boundary even for an unchanged
398// view. Callers retain the ordinary attachment send/effect ownership.
399func (p *preparedOutput) publishNoBytes(confirm bool, send func(protocol.UIViewUpdate) error) error {
400	if p == nil || p.stream == nil || p.attempted {
401		return nil
402	}
403	p.stream.lockView()
404	defer p.stream.unlockView()
405	p.attempted = true
406	if !p.currentLocked() || len(p.data) != 0 {
407		return nil
408	}
409	if p.context != nil {
410		candidate := *p.context
411		candidate.Publication = p.stream.viewPublication
412		if confirm || candidate != p.stream.lastViewContext {
413			if p.stream.next == 0 {
414				return errors.New("view update requires committed output")
415			}
416			if candidate.Publication == ^uint64(0) {
417				return errors.New("view publication number exhausted")
418			}
419			candidate.Publication++
420			if err := candidate.Validate(); err != nil {
421				return err
422			}
423			if err := send(protocol.UIViewUpdate{Epoch: p.epoch, State: p.stream.next, Context: candidate}); err != nil {
424				return err
425			}
426			p.stream.viewPublication = candidate.Publication
427			p.stream.lastViewContext = candidate
428			p.boundary = protocol.UIReceipt{Epoch: p.epoch, State: p.stream.next, ViewPublication: candidate.Publication, Outcome: protocol.UIReceiptProcessed}
429		}
430	}
431	p.commit()
432	p.sent = true
433	return nil
434}
435
436func (p *preparedOutput) commit() {
437	p.draw.Commit()
438	p.stream.forceSnapshot = false
439}
440
441func (s *attachmentOutput) currentEpoch() uint64 {
442	s.lockView()
443	defer s.unlockView()
444	return s.currentEpochLocked()
445}
446
447func (s *attachmentOutput) currentEpochLocked() uint64 {
448	if s.epoch == 0 {
449		s.epoch = 1
450	}
451	return s.epoch
452}
453
454func (s *attachmentOutput) fenceLocked() (uint64, uint64, uint64) {
455	if s == nil {
456		return 0, 1, 0
457	}
458	generation, revision := uint64(0), uint64(0)
459	if s.attachment != nil {
460		generation = s.attachment.lifecycle.generationValue()
461		revision = s.attachment.view.revision
462	}
463	return generation, s.currentEpochLocked(), revision
464}
465
466func (s *attachmentOutput) preparedCurrentLocked(generation, epoch, viewRevision, base uint64) bool {
467	if s == nil || s.currentEpochLocked() != epoch || s.generation != generation || s.next != base {
468		return false
469	}
470	if s.attachment == nil {
471		return true
472	}
473	return s.attachment.view.revision == viewRevision
474}
475
476func (p *preparedOutput) currentLocked() bool {
477	return p != nil && p.stream != nil && p.stream.preparedCurrentLocked(p.generation, p.epoch, p.viewRevision, p.base) &&
478		(p.stream.attachment == nil || p.stream.attachment.lifecycle.generationValue() == p.connectionGeneration)
479}
480
481func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision uint64, size domain.Size, full bool, context *protocol.ViewContext) (protocol.Output, error) {
482	output := protocol.Output{
483		Epoch: epoch, Base: base, New: next, Echo: echoAck, ViewRevision: viewRevision,
484		Size: size, Full: full, Data: data, Context: context,
485	}
486	if err := protocol.ValidateOutput(output); err != nil {
487		return protocol.Output{}, err
488	}
489	return output, nil
490}
491
492// sideEffect builds output without advancing the state stream. When an
493// attachment is present, callers must hold its sendMu; attachment-bound sends
494// use sideEffectLocked after validating their connection incarnation.
495func (s *attachmentOutput) sideEffect(data []byte, echoAck uint64) (protocol.Output, error) {
496	s.lockView()
497	defer s.unlockView()
498	return s.sideEffectLocked(data, echoAck)
499}
500
501// sideEffectLocked requires the attachment view lock for both output creation
502// and connection emission. Keeping that lock across the send prevents a view
503// publication from overtaking output fenced just before it.
504func (s *attachmentOutput) sideEffectLocked(data []byte, echoAck uint64) (protocol.Output, error) {
505	_, epoch, viewRevision := s.fenceLocked()
506	size := domain.Size{Cols: 1, Rows: 1}
507	if s != nil && s.attachment != nil {
508		size = s.attachment.sizeSnapshot()
509	}
510	return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false, nil)
511}
512
513func (s *attachmentOutput) ack(epoch, state uint64) bool {
514	if s == nil {
515		return false
516	}
517	s.lockView()
518	defer s.unlockView()
519	if epoch != s.currentEpochLocked() || state > s.next || state <= s.acked {
520		return false
521	}
522	s.acked = state
523	s.publishOutstanding()
524	return true
525}
526
527func (s *attachmentOutput) rebase() {
528	if s == nil {
529		return
530	}
531	s.lockView()
532	defer s.unlockView()
533	s.rebaseLocked()
534}
535
536// rebaseAttachment retires all output state coupled to the attachment's
537// current session or transport while preserving independent client state.
538func (s *attachmentOutput) rebaseAttachment() {
539	if s == nil {
540		return
541	}
542	s.rebase()
543	s.lastRoutePosition = protocol.RoutePosition{}
544}
545
546// rebaseLocked is called while the attachment view lock is held. Publishing
547// the next view revision takes this path before making that revision visible.
548func (s *attachmentOutput) rebaseLocked() {
549	if s == nil {
550		return
551	}
552	epoch := s.currentEpochLocked()
553	if epoch == ^uint64(0) {
554		return
555	}
556	s.epoch = epoch + 1
557	s.next = 0
558	s.acked = 0
559	s.generation++
560	s.publishOutstanding()
561	s.maxOutstandingAtomic.Store(s.maxOutstanding)
562	s.renderer.Reset()
563	s.forceSnapshot = true
564	s.initialized = false
565}
566
567func (s *attachmentOutput) publishOutstanding() {
568	if s == nil {
569		return
570	}
571	s.outstandingAtomic.Store(s.next - s.acked)
572}
573
574// syncCapacityLocked refreshes the lock-free readiness snapshot after a
575// caller mutates the legacy fields under attachedClient.sendMu.
576func (s *attachmentOutput) syncCapacityLocked() {
577	if s == nil {
578		return
579	}
580	s.lockView()
581	defer s.unlockView()
582	s.maxOutstandingAtomic.Store(s.maxOutstanding)
583	s.publishOutstanding()
584}
585
586func (s *attachmentOutput) setWindow(window uint8) {
587	if s == nil {
588		return
589	}
590	s.maxOutstanding = uint64(normalizeOutputWindow(window))
591	s.maxOutstandingAtomic.Store(s.maxOutstanding)
592}
593
594func (s *attachmentOutput) atCapacity() bool {
595	if s == nil {
596		return false
597	}
598	maxOutstanding := s.maxOutstandingAtomic.Load()
599	if maxOutstanding == 0 {
600		maxOutstanding = s.maxOutstanding
601	}
602	return s.outstandingAtomic.Load() >= maxOutstanding
603}
604
605// committedState reports the latest committed output state number. It takes
606// only the view lock, so barrier snapshots can read it without joining the
607// sendMu-ordered paint path.
608func (s *attachmentOutput) committedState() uint64 {
609	if s == nil {
610		return 0
611	}
612	s.lockView()
613	defer s.unlockView()
614	return s.next
615}
616
617func (s *attachmentOutput) outstanding() uint64 {
618	if s == nil {
619		return 0
620	}
621	s.lockView()
622	defer s.unlockView()
623	return s.next - s.acked
624}

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