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}