detached bf4f2c5

refactor(output): deepen attachment output runtime (#218)

* refactor(output): deepen attachment output runtime

* test(output): table-drive window normalization cases
commit
bf4f2c56959769a50e19f932e179ec1ac36a41cc
author
bnema · · 2026-08-26 19:54 UTC
committer
GitHub · · 2026-08-26 19:54 UTC
parent
0425df6
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
9 files · +210 -119
R internal/usecase/daemon/output_state_stream.go → internal/usecase/daemon/attachment_output.go +125 -25 renamed, 62% similar
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "errors"
5+ "strconv"
56 "sync"
67 "sync/atomic"
78
@@ -10,16 +11,16 @@ import (
1011 "github.com/bnema/vev/internal/ports"
1112 )
1213
13-// outputStateStream owns the terminal-output dependency chain for one
14-// attached client. Each incremental output is rendered from the preceding
15-// emitted frame and therefore remains pipelineable without waiting for an
14+// attachmentOutput owns the terminal-output dependency chain and emitted
15+// render state for one attached client. Each incremental output is rendered
16+// from the preceding emitted frame and remains pipelineable without waiting for an
1617 // ACK. Cumulative ACKs advance the bounded paint window.
1718 // A reset frame starts a new epoch, clears the dependency chain, and advertises
1819 // state 0 as its base.
1920 //
2021 // Callers serialize access with attachedClient.sendMu. A prepared transaction
2122 // must be sent or committed as a no-op before the next transaction is prepared.
22-type outputStateStream struct {
23+type attachmentOutput struct {
2324 renderer *renderer.Renderer
2425 epoch uint64
2526 next uint64
@@ -30,6 +31,8 @@ type outputStateStream struct {
3031 maxOutstandingAtomic atomic.Uint64
3132 forceSnapshot bool
3233 initialized bool
34+ lastCursor cursorOut
35+ lastRoutePosition ports.RoutePosition
3336 attachment *attachedClient
3437 stateMu sync.Mutex
3538 }
@@ -38,7 +41,7 @@ type outputStateStream struct {
3841 // state transition. It deliberately does not take sendMu: callers that hold
3942 // architecture locks can publish a view without reversing the paint path's
4043 // sendMu -> session lock order.
41-func (s *outputStateStream) lockView() {
44+func (s *attachmentOutput) lockView() {
4245 if s == nil {
4346 return
4447 }
@@ -49,7 +52,7 @@ func (s *outputStateStream) lockView() {
4952 s.attachment.viewMu.Lock()
5053 }
5154
52-func (s *outputStateStream) unlockView() {
55+func (s *attachmentOutput) unlockView() {
5356 if s == nil {
5457 return
5558 }
@@ -60,11 +63,11 @@ func (s *outputStateStream) unlockView() {
6063 s.attachment.viewMu.Unlock()
6164 }
6265
63-func newOutputStateStream(windowSize ...uint8) *outputStateStream {
66+func newOutputStateStream(windowSize ...uint8) *attachmentOutput {
6467 return newOutputStateStreamForProfile(renderer.ColorProfileTrueColor, windowSize...)
6568 }
6669
67-func newOutputStateStreamForCapabilities(capabilities ports.TerminalCapabilities, windowSize ...uint8) *outputStateStream {
70+func newOutputStateStreamForCapabilities(capabilities ports.TerminalCapabilities, windowSize ...uint8) *attachmentOutput {
6871 profile := renderer.ColorProfileANSI256
6972 if capabilities.TrueColor() {
7073 profile = renderer.ColorProfileTrueColor
@@ -72,12 +75,12 @@ func newOutputStateStreamForCapabilities(capabilities ports.TerminalCapabilities
7275 return newOutputStateStreamForProfile(profile, windowSize...)
7376 }
7477
75-func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *outputStateStream {
78+func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *attachmentOutput {
7679 window := uint8(maxUnackedOutputStates)
7780 if len(windowSize) > 0 {
7881 window = normalizeOutputWindow(windowSize[0])
7982 }
80- stream := &outputStateStream{
83+ stream := &attachmentOutput{
8184 renderer: renderer.NewWithColorProfile(renderer.Capabilities{}, profile),
8285 epoch: 1,
8386 maxOutstanding: uint64(window),
@@ -86,8 +89,87 @@ func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ..
8689 return stream
8790 }
8891
92+type cursorCandidate struct {
93+ data []byte
94+ next cursorOut
95+}
96+
97+// preparedAttachmentOutput is one speculative terminal-output transaction.
98+// ANSI renderer state and cursor state advance together only after the output
99+// frame is accepted by the attachment's state stream.
100+type preparedAttachmentOutput struct {
101+ output *attachmentOutput
102+ ansi *preparedOutput
103+ cursor cursorCandidate
104+ data []byte
105+}
106+
107+func (o *attachmentOutput) prepareFrame(frame renderer.Frame, damage []renderer.Damage, reset bool, desired cursorOut) (*preparedAttachmentOutput, error) {
108+ ansi, err := o.prepare(frame, damage, reset)
109+ if err != nil {
110+ return nil, err
111+ }
112+ cursor := o.prepareCursorTail(desired, len(ansi.data) > 0)
113+ data := append([]byte(nil), ansi.data...)
114+ data = append(data, cursor.data...)
115+ return &preparedAttachmentOutput{output: o, ansi: ansi, cursor: cursor, data: data}, nil
116+}
117+
118+func (p *preparedAttachmentOutput) send(echoAck uint64, send func(ports.Frame) error) error {
119+ if p == nil || p.ansi == nil {
120+ return nil
121+ }
122+ if err := p.ansi.send(p.data, echoAck, send); err != nil {
123+ return err
124+ }
125+ if p.ansi.sent && p.output != nil {
126+ p.output.lastCursor = p.cursor.next
127+ }
128+ return nil
129+}
130+
131+func (p *preparedAttachmentOutput) commitNoSend() {
132+ if p == nil || p.ansi == nil {
133+ return
134+ }
135+ p.ansi.commitNoSend()
136+ if p.ansi.sent && p.output != nil {
137+ p.output.lastCursor = p.cursor.next
138+ }
139+}
140+
141+func (p *preparedAttachmentOutput) sent() bool {
142+ return p != nil && p.ansi != nil && p.ansi.sent
143+}
144+
145+func (o *attachmentOutput) prepareCursorTail(desired cursorOut, force bool) cursorCandidate {
146+ candidate := cursorCandidate{next: desired}
147+ candidate.next.valid = true
148+ prev := o.lastCursor
149+ changed := force || !prev.valid || prev.hidden != desired.hidden || prev.row != desired.row || prev.col != desired.col || prev.style != desired.style || prev.hasStyle != desired.hasStyle
150+ if !changed {
151+ return candidate
152+ }
153+ if desired.hidden {
154+ candidate.data = []byte("\x1b[?25l")
155+ return candidate
156+ }
157+ candidate.data = append(candidate.data, "\x1b["...)
158+ candidate.data = strconv.AppendInt(candidate.data, int64(desired.row+1), 10)
159+ candidate.data = append(candidate.data, ';')
160+ candidate.data = strconv.AppendInt(candidate.data, int64(desired.col+1), 10)
161+ candidate.data = append(candidate.data, 'H')
162+ if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
163+ candidate.data = append(candidate.data, "\x1b["...)
164+ candidate.data = strconv.AppendInt(candidate.data, int64(desired.style), 10)
165+ candidate.data = append(candidate.data, " q"...)
166+ }
167+ candidate.data = append(candidate.data, "\x1b[?25h"...)
168+ return candidate
169+}
170+
89171 type preparedOutput struct {
90- stream *outputStateStream
172+ stream *attachmentOutput
91173 draw renderer.PreparedDraw
92174 base uint64
93175 next uint64
@@ -102,7 +184,7 @@ type preparedOutput struct {
102184 sent bool
103185 }
104186
105-func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
187+func (s *attachmentOutput) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
106188 s.lockView()
107189 defer s.unlockView()
108190 if s.epoch == 0 {
@@ -190,20 +272,20 @@ func (p *preparedOutput) commit() {
190272 p.stream.forceSnapshot = false
191273 }
192274
193-func (s *outputStateStream) currentEpoch() uint64 {
275+func (s *attachmentOutput) currentEpoch() uint64 {
194276 s.lockView()
195277 defer s.unlockView()
196278 return s.currentEpochLocked()
197279 }
198280
199-func (s *outputStateStream) currentEpochLocked() uint64 {
281+func (s *attachmentOutput) currentEpochLocked() uint64 {
200282 if s.epoch == 0 {
201283 s.epoch = 1
202284 }
203285 return s.epoch
204286 }
205287
206-func (s *outputStateStream) fenceLocked() (uint64, uint64, uint64) {
288+func (s *attachmentOutput) fenceLocked() (uint64, uint64, uint64) {
207289 if s == nil {
208290 return 0, 1, 0
209291 }
@@ -215,7 +297,7 @@ func (s *outputStateStream) fenceLocked() (uint64, uint64, uint64) {
215297 return generation, s.currentEpochLocked(), revision
216298 }
217299
218-func (s *outputStateStream) preparedCurrentLocked(generation, epoch, viewRevision, base uint64) bool {
300+func (s *attachmentOutput) preparedCurrentLocked(generation, epoch, viewRevision, base uint64) bool {
219301 if s == nil || s.currentEpochLocked() != epoch || s.generation != generation || s.next != base {
220302 return false
221303 }
@@ -244,7 +326,7 @@ func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision ui
244326 // sideEffect builds an output frame without advancing the state stream. When
245327 // an attachment is present, callers must hold its sendMu; attachment-bound
246328 // sends use sideEffectLocked after validating their transport incarnation.
247-func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) (ports.Frame, error) {
329+func (s *attachmentOutput) sideEffect(data []byte, echoAck uint64) (ports.Frame, error) {
248330 s.lockView()
249331 defer s.unlockView()
250332 return s.sideEffectLocked(data, echoAck)
@@ -253,7 +335,7 @@ func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) (ports.Frame
253335 // sideEffectLocked requires the attachment view lock for both frame creation
254336 // and transport emission. Keeping that lock across the send prevents a view
255337 // publication from overtaking a frame that was fenced just before it.
256-func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports.Frame, error) {
338+func (s *attachmentOutput) sideEffectLocked(data []byte, echoAck uint64) (ports.Frame, error) {
257339 _, epoch, viewRevision := s.fenceLocked()
258340 size := domain.Size{Cols: 1, Rows: 1}
259341 if s != nil && s.attachment != nil {
@@ -262,7 +344,7 @@ func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports
262344 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
263345 }
264346
265-func (s *outputStateStream) ack(epoch, state uint64) bool {
347+func (s *attachmentOutput) ack(epoch, state uint64) bool {
266348 if s == nil {
267349 return false
268350 }
@@ -276,7 +358,7 @@ func (s *outputStateStream) ack(epoch, state uint64) bool {
276358 return true
277359 }
278360
279-func (s *outputStateStream) rebase() {
361+func (s *attachmentOutput) rebase() {
280362 if s == nil {
281363 return
282364 }
@@ -285,9 +367,19 @@ func (s *outputStateStream) rebase() {
285367 s.rebaseLocked()
286368 }
287369
370+// rebaseAttachment retires all output state coupled to the attachment's
371+// current session or transport while preserving independent client state.
372+func (s *attachmentOutput) rebaseAttachment() {
373+ if s == nil {
374+ return
375+ }
376+ s.rebase()
377+ s.lastRoutePosition = ports.RoutePosition{}
378+}
379+
288380 // rebaseLocked is called while the attachment view lock is held. Publishing
289381 // the next view revision takes this path before making that revision visible.
290-func (s *outputStateStream) rebaseLocked() {
382+func (s *attachmentOutput) rebaseLocked() {
291383 if s == nil {
292384 return
293385 }
@@ -306,7 +398,7 @@ func (s *outputStateStream) rebaseLocked() {
306398 s.initialized = false
307399 }
308400
309-func (s *outputStateStream) publishOutstanding() {
401+func (s *attachmentOutput) publishOutstanding() {
310402 if s == nil {
311403 return
312404 }
@@ -315,7 +407,7 @@ func (s *outputStateStream) publishOutstanding() {
315407
316408 // syncCapacityLocked refreshes the lock-free readiness snapshot after a
317409 // caller mutates the legacy fields under attachedClient.sendMu.
318-func (s *outputStateStream) syncCapacityLocked() {
410+func (s *attachmentOutput) syncCapacityLocked() {
319411 if s == nil {
320412 return
321413 }
@@ -325,7 +417,15 @@ func (s *outputStateStream) syncCapacityLocked() {
325417 s.publishOutstanding()
326418 }
327419
328-func (s *outputStateStream) atCapacity() bool {
420+func (s *attachmentOutput) setWindow(window uint8) {
421+ if s == nil {
422+ return
423+ }
424+ s.maxOutstanding = uint64(normalizeOutputWindow(window))
425+ s.maxOutstandingAtomic.Store(s.maxOutstanding)
426+}
427+
428+func (s *attachmentOutput) atCapacity() bool {
329429 if s == nil {
330430 return false
331431 }
@@ -336,7 +436,7 @@ func (s *outputStateStream) atCapacity() bool {
336436 return s.outstandingAtomic.Load() >= maxOutstanding
337437 }
338438
339-func (s *outputStateStream) outstanding() uint64 {
439+func (s *attachmentOutput) outstanding() uint64 {
340440 if s == nil {
341441 return 0
342442 }
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "strconv"
5 "sync"6 "sync"
6 "sync/atomic"7 "sync/atomic"
7 8
@@ -10,16 +11,16 @@ import (
10 "github.com/bnema/vev/internal/ports"11 "github.com/bnema/vev/internal/ports"
11 )12 )
12 13
13-// outputStateStream owns the terminal-output dependency chain for one14+// attachmentOutput owns the terminal-output dependency chain and emitted
14-// attached client. Each incremental output is rendered from the preceding15+// render state for one attached client. Each incremental output is rendered
15-// emitted frame and therefore remains pipelineable without waiting for an16+// from the preceding emitted frame and remains pipelineable without waiting for an
16 // ACK. Cumulative ACKs advance the bounded paint window.17 // ACK. Cumulative ACKs advance the bounded paint window.
17 // A reset frame starts a new epoch, clears the dependency chain, and advertises18 // A reset frame starts a new epoch, clears the dependency chain, and advertises
18 // state 0 as its base.19 // state 0 as its base.
19 //20 //
20 // Callers serialize access with attachedClient.sendMu. A prepared transaction21 // Callers serialize access with attachedClient.sendMu. A prepared transaction
21 // must be sent or committed as a no-op before the next transaction is prepared.22 // must be sent or committed as a no-op before the next transaction is prepared.
22-type outputStateStream struct {23+type attachmentOutput struct {
23 renderer *renderer.Renderer24 renderer *renderer.Renderer
24 epoch uint6425 epoch uint64
25 next uint6426 next uint64
@@ -30,6 +31,8 @@ type outputStateStream struct {
30 maxOutstandingAtomic atomic.Uint6431 maxOutstandingAtomic atomic.Uint64
31 forceSnapshot bool32 forceSnapshot bool
32 initialized bool33 initialized bool
34+ lastCursor cursorOut
35+ lastRoutePosition ports.RoutePosition
33 attachment *attachedClient36 attachment *attachedClient
34 stateMu sync.Mutex37 stateMu sync.Mutex
35 }38 }
@@ -38,7 +41,7 @@ type outputStateStream struct {
38 // state transition. It deliberately does not take sendMu: callers that hold41 // state transition. It deliberately does not take sendMu: callers that hold
39 // architecture locks can publish a view without reversing the paint path's42 // architecture locks can publish a view without reversing the paint path's
40 // sendMu -> session lock order.43 // sendMu -> session lock order.
41-func (s *outputStateStream) lockView() {44+func (s *attachmentOutput) lockView() {
42 if s == nil {45 if s == nil {
43 return46 return
44 }47 }
@@ -49,7 +52,7 @@ func (s *outputStateStream) lockView() {
49 s.attachment.viewMu.Lock()52 s.attachment.viewMu.Lock()
50 }53 }
51 54
52-func (s *outputStateStream) unlockView() {55+func (s *attachmentOutput) unlockView() {
53 if s == nil {56 if s == nil {
54 return57 return
55 }58 }
@@ -60,11 +63,11 @@ func (s *outputStateStream) unlockView() {
60 s.attachment.viewMu.Unlock()63 s.attachment.viewMu.Unlock()
61 }64 }
62 65
63-func newOutputStateStream(windowSize ...uint8) *outputStateStream {66+func newOutputStateStream(windowSize ...uint8) *attachmentOutput {
64 return newOutputStateStreamForProfile(renderer.ColorProfileTrueColor, windowSize...)67 return newOutputStateStreamForProfile(renderer.ColorProfileTrueColor, windowSize...)
65 }68 }
66 69
67-func newOutputStateStreamForCapabilities(capabilities ports.TerminalCapabilities, windowSize ...uint8) *outputStateStream {70+func newOutputStateStreamForCapabilities(capabilities ports.TerminalCapabilities, windowSize ...uint8) *attachmentOutput {
68 profile := renderer.ColorProfileANSI25671 profile := renderer.ColorProfileANSI256
69 if capabilities.TrueColor() {72 if capabilities.TrueColor() {
70 profile = renderer.ColorProfileTrueColor73 profile = renderer.ColorProfileTrueColor
@@ -72,12 +75,12 @@ func newOutputStateStreamForCapabilities(capabilities ports.TerminalCapabilities
72 return newOutputStateStreamForProfile(profile, windowSize...)75 return newOutputStateStreamForProfile(profile, windowSize...)
73 }76 }
74 77
75-func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *outputStateStream {78+func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ...uint8) *attachmentOutput {
76 window := uint8(maxUnackedOutputStates)79 window := uint8(maxUnackedOutputStates)
77 if len(windowSize) > 0 {80 if len(windowSize) > 0 {
78 window = normalizeOutputWindow(windowSize[0])81 window = normalizeOutputWindow(windowSize[0])
79 }82 }
80- stream := &outputStateStream{83+ stream := &attachmentOutput{
81 renderer: renderer.NewWithColorProfile(renderer.Capabilities{}, profile),84 renderer: renderer.NewWithColorProfile(renderer.Capabilities{}, profile),
82 epoch: 1,85 epoch: 1,
83 maxOutstanding: uint64(window),86 maxOutstanding: uint64(window),
@@ -86,8 +89,87 @@ func newOutputStateStreamForProfile(profile renderer.ColorProfile, windowSize ..
86 return stream89 return stream
87 }90 }
88 91
92+type cursorCandidate struct {
93+ data []byte
94+ next cursorOut
95+}
96+
97+// preparedAttachmentOutput is one speculative terminal-output transaction.
98+// ANSI renderer state and cursor state advance together only after the output
99+// frame is accepted by the attachment's state stream.
100+type preparedAttachmentOutput struct {
101+ output *attachmentOutput
102+ ansi *preparedOutput
103+ cursor cursorCandidate
104+ data []byte
105+}
106+
107+func (o *attachmentOutput) prepareFrame(frame renderer.Frame, damage []renderer.Damage, reset bool, desired cursorOut) (*preparedAttachmentOutput, error) {
108+ ansi, err := o.prepare(frame, damage, reset)
109+ if err != nil {
110+ return nil, err
111+ }
112+ cursor := o.prepareCursorTail(desired, len(ansi.data) > 0)
113+ data := append([]byte(nil), ansi.data...)
114+ data = append(data, cursor.data...)
115+ return &preparedAttachmentOutput{output: o, ansi: ansi, cursor: cursor, data: data}, nil
116+}
117+
118+func (p *preparedAttachmentOutput) send(echoAck uint64, send func(ports.Frame) error) error {
119+ if p == nil || p.ansi == nil {
120+ return nil
121+ }
122+ if err := p.ansi.send(p.data, echoAck, send); err != nil {
123+ return err
124+ }
125+ if p.ansi.sent && p.output != nil {
126+ p.output.lastCursor = p.cursor.next
127+ }
128+ return nil
129+}
130+
131+func (p *preparedAttachmentOutput) commitNoSend() {
132+ if p == nil || p.ansi == nil {
133+ return
134+ }
135+ p.ansi.commitNoSend()
136+ if p.ansi.sent && p.output != nil {
137+ p.output.lastCursor = p.cursor.next
138+ }
139+}
140+
141+func (p *preparedAttachmentOutput) sent() bool {
142+ return p != nil && p.ansi != nil && p.ansi.sent
143+}
144+
145+func (o *attachmentOutput) prepareCursorTail(desired cursorOut, force bool) cursorCandidate {
146+ candidate := cursorCandidate{next: desired}
147+ candidate.next.valid = true
148+ prev := o.lastCursor
149+ changed := force || !prev.valid || prev.hidden != desired.hidden || prev.row != desired.row || prev.col != desired.col || prev.style != desired.style || prev.hasStyle != desired.hasStyle
150+ if !changed {
151+ return candidate
152+ }
153+ if desired.hidden {
154+ candidate.data = []byte("\x1b[?25l")
155+ return candidate
156+ }
157+ candidate.data = append(candidate.data, "\x1b["...)
158+ candidate.data = strconv.AppendInt(candidate.data, int64(desired.row+1), 10)
159+ candidate.data = append(candidate.data, ';')
160+ candidate.data = strconv.AppendInt(candidate.data, int64(desired.col+1), 10)
161+ candidate.data = append(candidate.data, 'H')
162+ if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
163+ candidate.data = append(candidate.data, "\x1b["...)
164+ candidate.data = strconv.AppendInt(candidate.data, int64(desired.style), 10)
165+ candidate.data = append(candidate.data, " q"...)
166+ }
167+ candidate.data = append(candidate.data, "\x1b[?25h"...)
168+ return candidate
169+}
170+
89 type preparedOutput struct {171 type preparedOutput struct {
90- stream *outputStateStream172+ stream *attachmentOutput
91 draw renderer.PreparedDraw173 draw renderer.PreparedDraw
92 base uint64174 base uint64
93 next uint64175 next uint64
@@ -102,7 +184,7 @@ type preparedOutput struct {
102 sent bool184 sent bool
103 }185 }
104 186
105-func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {187+func (s *attachmentOutput) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
106 s.lockView()188 s.lockView()
107 defer s.unlockView()189 defer s.unlockView()
108 if s.epoch == 0 {190 if s.epoch == 0 {
@@ -190,20 +272,20 @@ func (p *preparedOutput) commit() {
190 p.stream.forceSnapshot = false272 p.stream.forceSnapshot = false
191 }273 }
192 274
193-func (s *outputStateStream) currentEpoch() uint64 {275+func (s *attachmentOutput) currentEpoch() uint64 {
194 s.lockView()276 s.lockView()
195 defer s.unlockView()277 defer s.unlockView()
196 return s.currentEpochLocked()278 return s.currentEpochLocked()
197 }279 }
198 280
199-func (s *outputStateStream) currentEpochLocked() uint64 {281+func (s *attachmentOutput) currentEpochLocked() uint64 {
200 if s.epoch == 0 {282 if s.epoch == 0 {
201 s.epoch = 1283 s.epoch = 1
202 }284 }
203 return s.epoch285 return s.epoch
204 }286 }
205 287
206-func (s *outputStateStream) fenceLocked() (uint64, uint64, uint64) {288+func (s *attachmentOutput) fenceLocked() (uint64, uint64, uint64) {
207 if s == nil {289 if s == nil {
208 return 0, 1, 0290 return 0, 1, 0
209 }291 }
@@ -215,7 +297,7 @@ func (s *outputStateStream) fenceLocked() (uint64, uint64, uint64) {
215 return generation, s.currentEpochLocked(), revision297 return generation, s.currentEpochLocked(), revision
216 }298 }
217 299
218-func (s *outputStateStream) preparedCurrentLocked(generation, epoch, viewRevision, base uint64) bool {300+func (s *attachmentOutput) preparedCurrentLocked(generation, epoch, viewRevision, base uint64) bool {
219 if s == nil || s.currentEpochLocked() != epoch || s.generation != generation || s.next != base {301 if s == nil || s.currentEpochLocked() != epoch || s.generation != generation || s.next != base {
220 return false302 return false
221 }303 }
@@ -244,7 +326,7 @@ func marshalOutputState(data []byte, epoch, base, next, echoAck, viewRevision ui
244 // sideEffect builds an output frame without advancing the state stream. When326 // sideEffect builds an output frame without advancing the state stream. When
245 // an attachment is present, callers must hold its sendMu; attachment-bound327 // an attachment is present, callers must hold its sendMu; attachment-bound
246 // sends use sideEffectLocked after validating their transport incarnation.328 // sends use sideEffectLocked after validating their transport incarnation.
247-func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) (ports.Frame, error) {329+func (s *attachmentOutput) sideEffect(data []byte, echoAck uint64) (ports.Frame, error) {
248 s.lockView()330 s.lockView()
249 defer s.unlockView()331 defer s.unlockView()
250 return s.sideEffectLocked(data, echoAck)332 return s.sideEffectLocked(data, echoAck)
@@ -253,7 +335,7 @@ func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) (ports.Frame
253 // sideEffectLocked requires the attachment view lock for both frame creation335 // sideEffectLocked requires the attachment view lock for both frame creation
254 // and transport emission. Keeping that lock across the send prevents a view336 // and transport emission. Keeping that lock across the send prevents a view
255 // publication from overtaking a frame that was fenced just before it.337 // publication from overtaking a frame that was fenced just before it.
256-func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports.Frame, error) {338+func (s *attachmentOutput) sideEffectLocked(data []byte, echoAck uint64) (ports.Frame, error) {
257 _, epoch, viewRevision := s.fenceLocked()339 _, epoch, viewRevision := s.fenceLocked()
258 size := domain.Size{Cols: 1, Rows: 1}340 size := domain.Size{Cols: 1, Rows: 1}
259 if s != nil && s.attachment != nil {341 if s != nil && s.attachment != nil {
@@ -262,7 +344,7 @@ func (s *outputStateStream) sideEffectLocked(data []byte, echoAck uint64) (ports
262 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)344 return marshalOutputState(data, epoch, 0, 0, echoAck, viewRevision, size, false)
263 }345 }
264 346
265-func (s *outputStateStream) ack(epoch, state uint64) bool {347+func (s *attachmentOutput) ack(epoch, state uint64) bool {
266 if s == nil {348 if s == nil {
267 return false349 return false
268 }350 }
@@ -276,7 +358,7 @@ func (s *outputStateStream) ack(epoch, state uint64) bool {
276 return true358 return true
277 }359 }
278 360
279-func (s *outputStateStream) rebase() {361+func (s *attachmentOutput) rebase() {
280 if s == nil {362 if s == nil {
281 return363 return
282 }364 }
@@ -285,9 +367,19 @@ func (s *outputStateStream) rebase() {
285 s.rebaseLocked()367 s.rebaseLocked()
286 }368 }
287 369
370+// rebaseAttachment retires all output state coupled to the attachment's
371+// current session or transport while preserving independent client state.
372+func (s *attachmentOutput) rebaseAttachment() {
373+ if s == nil {
374+ return
375+ }
376+ s.rebase()
377+ s.lastRoutePosition = ports.RoutePosition{}
378+}
379+
288 // rebaseLocked is called while the attachment view lock is held. Publishing380 // rebaseLocked is called while the attachment view lock is held. Publishing
289 // the next view revision takes this path before making that revision visible.381 // the next view revision takes this path before making that revision visible.
290-func (s *outputStateStream) rebaseLocked() {382+func (s *attachmentOutput) rebaseLocked() {
291 if s == nil {383 if s == nil {
292 return384 return
293 }385 }
@@ -306,7 +398,7 @@ func (s *outputStateStream) rebaseLocked() {
306 s.initialized = false398 s.initialized = false
307 }399 }
308 400
309-func (s *outputStateStream) publishOutstanding() {401+func (s *attachmentOutput) publishOutstanding() {
310 if s == nil {402 if s == nil {
311 return403 return
312 }404 }
@@ -315,7 +407,7 @@ func (s *outputStateStream) publishOutstanding() {
315 407
316 // syncCapacityLocked refreshes the lock-free readiness snapshot after a408 // syncCapacityLocked refreshes the lock-free readiness snapshot after a
317 // caller mutates the legacy fields under attachedClient.sendMu.409 // caller mutates the legacy fields under attachedClient.sendMu.
318-func (s *outputStateStream) syncCapacityLocked() {410+func (s *attachmentOutput) syncCapacityLocked() {
319 if s == nil {411 if s == nil {
320 return412 return
321 }413 }
@@ -325,7 +417,15 @@ func (s *outputStateStream) syncCapacityLocked() {
325 s.publishOutstanding()417 s.publishOutstanding()
326 }418 }
327 419
328-func (s *outputStateStream) atCapacity() bool {420+func (s *attachmentOutput) setWindow(window uint8) {
421+ if s == nil {
422+ return
423+ }
424+ s.maxOutstanding = uint64(normalizeOutputWindow(window))
425+ s.maxOutstandingAtomic.Store(s.maxOutstanding)
426+}
427+
428+func (s *attachmentOutput) atCapacity() bool {
329 if s == nil {429 if s == nil {
330 return false430 return false
331 }431 }
@@ -336,7 +436,7 @@ func (s *outputStateStream) atCapacity() bool {
336 return s.outstandingAtomic.Load() >= maxOutstanding436 return s.outstandingAtomic.Load() >= maxOutstanding
337 }437 }
338 438
339-func (s *outputStateStream) outstanding() uint64 {439+func (s *attachmentOutput) outstanding() uint64 {
340 if s == nil {440 if s == nil {
341 return 0441 return 0
342 }442 }
R internal/usecase/daemon/output_state_stream_test.go → internal/usecase/daemon/attachment_output_test.go +49 -17 renamed, 86% similar
@@ -12,7 +12,7 @@ import (
1212 "github.com/stretchr/testify/require"
1313 )
1414
15-func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) {
15+func TestAttachmentOutputBuildsPipelinedDependencyChain(t *testing.T) {
1616 stream := newOutputStateStream()
1717 firstFrame := renderer.NewFrame(3, 1)
1818 fillOutputStateRows(firstFrame, []string{"abc"})
@@ -37,7 +37,7 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) {
3737 require.Equal(t, uint64(2), stream.outstanding())
3838 }
3939
40-func TestOutputStateStreamUsesTerminalColorProfile(t *testing.T) {
40+func TestAttachmentOutputUsesTerminalColorProfile(t *testing.T) {
4141 frame := renderer.NewFrame(1, 1)
4242 frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.Style{
4343 Foreground: -1,
@@ -77,7 +77,7 @@ func TestOutputStateStreamUsesTerminalColorProfile(t *testing.T) {
7777 }
7878 }
7979
80-func TestOutputStateStreamCapacityProbeDoesNotRaceWithSend(t *testing.T) {
80+func TestAttachmentOutputCapacityProbeDoesNotRaceWithSend(t *testing.T) {
8181 stream := newOutputStateStream(1)
8282 frame := renderer.NewFrame(3, 1)
8383 fillOutputStateRows(frame, []string{"abc"})
@@ -115,10 +115,10 @@ func TestOutputStateStreamCapacityProbeDoesNotRaceWithSend(t *testing.T) {
115115 require.False(t, stream.atCapacity())
116116 }
117117
118-func TestOutputStateStreamEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *testing.T) {
118+func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *testing.T) {
119119 frame := renderer.NewFrame(3, 1)
120120 fillOutputStateRows(frame, []string{"abc"})
121- newAttachment := func() *outputStateStream {
121+ newAttachment := func() *attachmentOutput {
122122 stream := newOutputStateStream()
123123 ac := &attachedClient{output: stream, size: domain.Size{Cols: 3, Rows: 1}}
124124 stream.attachment = ac
@@ -251,7 +251,7 @@ func TestOutputStateSideEffectsDoNotAdvanceEpochStateOrACK(t *testing.T) {
251251 require.Equal(t, beforeAck, stream.acked)
252252 }
253253
254-func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {
254+func TestAttachmentOutputFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {
255255 stream := newOutputStateStream()
256256 initial := renderer.NewFrame(3, 1)
257257 fillOutputStateRows(initial, []string{"abc"})
@@ -298,7 +298,7 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T
298298 require.Empty(t, probe.Bytes(), "successful retry commits the candidate shadow")
299299 }
300300
301-func TestOutputStateStreamNoByteCommitAdvancesShadowWithoutState(t *testing.T) {
301+func TestAttachmentOutputNoByteCommitAdvancesShadowWithoutState(t *testing.T) {
302302 stream := newOutputStateStream()
303303 frame := renderer.NewFrame(3, 1)
304304 fillOutputStateRows(frame, []string{"abc"})
@@ -318,7 +318,7 @@ func TestOutputStateStreamNoByteCommitAdvancesShadowWithoutState(t *testing.T) {
318318 require.Empty(t, probe.Bytes(), "no-byte commit retains the prepared visual candidate")
319319 }
320320
321-func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T) {
321+func TestAttachmentOutputRepeatedUnackedScrollRemainsClientCorrect(t *testing.T) {
322322 stream := newOutputStateStream()
323323 client := vt.NewScreen(4, 3)
324324 initial := renderer.NewFrame(4, 3)
@@ -352,7 +352,7 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T
352352 }
353353 }
354354
355-func (s *outputStateStream) render(frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) {
355+func (s *attachmentOutput) render(frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) {
356356 prepared, err := s.prepare(frame, damage, reset)
357357 if err != nil {
358358 return nil, err
@@ -368,7 +368,7 @@ func (s *outputStateStream) render(frame renderer.Frame, damage []renderer.Damag
368368 return prepared.data, nil
369369 }
370370
371-func outputStateFrame(stream *outputStateStream, data []byte, reset bool, echoAck uint64) ports.Frame {
371+func outputStateFrame(stream *attachmentOutput, data []byte, reset bool, echoAck uint64) ports.Frame {
372372 stream.next++
373373 base := stream.next - 1
374374 if reset {
@@ -381,7 +381,7 @@ func outputStateFrame(stream *outputStateStream, data []byte, reset bool, echoAc
381381 return frame
382382 }
383383
384-func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
384+func drawOutputState(t *testing.T, stream *attachmentOutput, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
385385 t.Helper()
386386 prepared, err := stream.prepare(frame, damage, reset)
387387 if err != nil || len(prepared.data) == 0 {
@@ -403,7 +403,7 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra
403403
404404 // A resize is a state reset, but it must not turn subsequent idle or damaged
405405 // renders into full frames.
406-func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing.T) {
406+func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing.T) {
407407 screen := vt.NewScreen(4, 2)
408408 screen.Write([]byte("abcd"))
409409 screen.ClearDamage()
@@ -431,11 +431,43 @@ func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing
431431 require.Equal(t, resizeOutput.New, damageOutput.Base, "later damage must remain incremental")
432432 }
433433
434-func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {
435- require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding)
436- require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding)
437- require.Equal(t, uint64(8), newOutputStateStream(9).maxOutstanding)
438- require.Equal(t, uint64(1), newOutputStateStream(1).maxOutstanding)
434+func TestAttachmentOutputRebaseRetiresAttachmentState(t *testing.T) {
435+ output := newOutputStateStream()
436+ output.next = 3
437+ output.acked = 2
438+ output.lastCursor = cursorOut{valid: true, row: 2, col: 3}
439+ output.lastRoutePosition = ports.RoutePosition{ActiveTabID: "tab"}
440+
441+ output.rebaseAttachment()
442+
443+ require.Equal(t, uint64(2), output.epoch)
444+ require.Zero(t, output.next)
445+ require.Zero(t, output.acked)
446+ require.True(t, output.forceSnapshot)
447+ require.Equal(t, cursorOut{valid: true, row: 2, col: 3}, output.lastCursor, "the forced frame will reassert cursor state")
448+ require.Equal(t, ports.RoutePosition{}, output.lastRoutePosition)
449+}
450+
451+func TestAttachmentOutputDefaultsAndNormalizesWindow(t *testing.T) {
452+ for _, tt := range []struct {
453+ name string
454+ window []uint8
455+ want uint64
456+ }{
457+ {name: "omitted", want: 8},
458+ {name: "zero", window: []uint8{0}, want: 8},
459+ {name: "oversized", window: []uint8{9}, want: 8},
460+ {name: "exact window", window: []uint8{1}, want: 1},
461+ } {
462+ t.Run(tt.name, func(t *testing.T) {
463+ require.Equal(t, tt.want, newOutputStateStream(tt.window...).maxOutstanding)
464+ })
465+ }
466+
467+ output := newOutputStateStream()
468+ output.setWindow(2)
469+ require.Equal(t, uint64(2), output.maxOutstanding)
470+ require.Equal(t, uint64(2), output.maxOutstandingAtomic.Load())
439471 }
440472
441473 func TestNormalizeOutputWindow(t *testing.T) {
@@ -12,7 +12,7 @@ import (
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 )13 )
14 14
15-func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) {15+func TestAttachmentOutputBuildsPipelinedDependencyChain(t *testing.T) {
16 stream := newOutputStateStream()16 stream := newOutputStateStream()
17 firstFrame := renderer.NewFrame(3, 1)17 firstFrame := renderer.NewFrame(3, 1)
18 fillOutputStateRows(firstFrame, []string{"abc"})18 fillOutputStateRows(firstFrame, []string{"abc"})
@@ -37,7 +37,7 @@ func TestOutputStateStreamBuildsPipelinedDependencyChain(t *testing.T) {
37 require.Equal(t, uint64(2), stream.outstanding())37 require.Equal(t, uint64(2), stream.outstanding())
38 }38 }
39 39
40-func TestOutputStateStreamUsesTerminalColorProfile(t *testing.T) {40+func TestAttachmentOutputUsesTerminalColorProfile(t *testing.T) {
41 frame := renderer.NewFrame(1, 1)41 frame := renderer.NewFrame(1, 1)
42 frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.Style{42 frame.Set(0, 0, renderer.Cell{Rune: 'X', Style: renderer.Style{
43 Foreground: -1,43 Foreground: -1,
@@ -77,7 +77,7 @@ func TestOutputStateStreamUsesTerminalColorProfile(t *testing.T) {
77 }77 }
78 }78 }
79 79
80-func TestOutputStateStreamCapacityProbeDoesNotRaceWithSend(t *testing.T) {80+func TestAttachmentOutputCapacityProbeDoesNotRaceWithSend(t *testing.T) {
81 stream := newOutputStateStream(1)81 stream := newOutputStateStream(1)
82 frame := renderer.NewFrame(3, 1)82 frame := renderer.NewFrame(3, 1)
83 fillOutputStateRows(frame, []string{"abc"})83 fillOutputStateRows(frame, []string{"abc"})
@@ -115,10 +115,10 @@ func TestOutputStateStreamCapacityProbeDoesNotRaceWithSend(t *testing.T) {
115 require.False(t, stream.atCapacity())115 require.False(t, stream.atCapacity())
116 }116 }
117 117
118-func TestOutputStateStreamEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *testing.T) {118+func TestAttachmentOutputEpochsAreAttachmentLocalAndPreparedFramesAreFenced(t *testing.T) {
119 frame := renderer.NewFrame(3, 1)119 frame := renderer.NewFrame(3, 1)
120 fillOutputStateRows(frame, []string{"abc"})120 fillOutputStateRows(frame, []string{"abc"})
121- newAttachment := func() *outputStateStream {121+ newAttachment := func() *attachmentOutput {
122 stream := newOutputStateStream()122 stream := newOutputStateStream()
123 ac := &attachedClient{output: stream, size: domain.Size{Cols: 3, Rows: 1}}123 ac := &attachedClient{output: stream, size: domain.Size{Cols: 3, Rows: 1}}
124 stream.attachment = ac124 stream.attachment = ac
@@ -251,7 +251,7 @@ func TestOutputStateSideEffectsDoNotAdvanceEpochStateOrACK(t *testing.T) {
251 require.Equal(t, beforeAck, stream.acked)251 require.Equal(t, beforeAck, stream.acked)
252 }252 }
253 253
254-func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {254+func TestAttachmentOutputFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T) {
255 stream := newOutputStateStream()255 stream := newOutputStateStream()
256 initial := renderer.NewFrame(3, 1)256 initial := renderer.NewFrame(3, 1)
257 fillOutputStateRows(initial, []string{"abc"})257 fillOutputStateRows(initial, []string{"abc"})
@@ -298,7 +298,7 @@ func TestOutputStateStreamFailedSendRetriesSnapshotWithoutAdvancing(t *testing.T
298 require.Empty(t, probe.Bytes(), "successful retry commits the candidate shadow")298 require.Empty(t, probe.Bytes(), "successful retry commits the candidate shadow")
299 }299 }
300 300
301-func TestOutputStateStreamNoByteCommitAdvancesShadowWithoutState(t *testing.T) {301+func TestAttachmentOutputNoByteCommitAdvancesShadowWithoutState(t *testing.T) {
302 stream := newOutputStateStream()302 stream := newOutputStateStream()
303 frame := renderer.NewFrame(3, 1)303 frame := renderer.NewFrame(3, 1)
304 fillOutputStateRows(frame, []string{"abc"})304 fillOutputStateRows(frame, []string{"abc"})
@@ -318,7 +318,7 @@ func TestOutputStateStreamNoByteCommitAdvancesShadowWithoutState(t *testing.T) {
318 require.Empty(t, probe.Bytes(), "no-byte commit retains the prepared visual candidate")318 require.Empty(t, probe.Bytes(), "no-byte commit retains the prepared visual candidate")
319 }319 }
320 320
321-func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T) {321+func TestAttachmentOutputRepeatedUnackedScrollRemainsClientCorrect(t *testing.T) {
322 stream := newOutputStateStream()322 stream := newOutputStateStream()
323 client := vt.NewScreen(4, 3)323 client := vt.NewScreen(4, 3)
324 initial := renderer.NewFrame(4, 3)324 initial := renderer.NewFrame(4, 3)
@@ -352,7 +352,7 @@ func TestOutputStateStreamRepeatedUnackedScrollRemainsClientCorrect(t *testing.T
352 }352 }
353 }353 }
354 354
355-func (s *outputStateStream) render(frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) {355+func (s *attachmentOutput) render(frame renderer.Frame, damage []renderer.Damage, reset bool) ([]byte, error) {
356 prepared, err := s.prepare(frame, damage, reset)356 prepared, err := s.prepare(frame, damage, reset)
357 if err != nil {357 if err != nil {
358 return nil, err358 return nil, err
@@ -368,7 +368,7 @@ func (s *outputStateStream) render(frame renderer.Frame, damage []renderer.Damag
368 return prepared.data, nil368 return prepared.data, nil
369 }369 }
370 370
371-func outputStateFrame(stream *outputStateStream, data []byte, reset bool, echoAck uint64) ports.Frame {371+func outputStateFrame(stream *attachmentOutput, data []byte, reset bool, echoAck uint64) ports.Frame {
372 stream.next++372 stream.next++
373 base := stream.next - 1373 base := stream.next - 1
374 if reset {374 if reset {
@@ -381,7 +381,7 @@ func outputStateFrame(stream *outputStateStream, data []byte, reset bool, echoAc
381 return frame381 return frame
382 }382 }
383 383
384-func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {384+func drawOutputState(t *testing.T, stream *attachmentOutput, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
385 t.Helper()385 t.Helper()
386 prepared, err := stream.prepare(frame, damage, reset)386 prepared, err := stream.prepare(frame, damage, reset)
387 if err != nil || len(prepared.data) == 0 {387 if err != nil || len(prepared.data) == 0 {
@@ -403,7 +403,7 @@ func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Fra
403 403
404 // A resize is a state reset, but it must not turn subsequent idle or damaged404 // A resize is a state reset, but it must not turn subsequent idle or damaged
405 // renders into full frames.405 // renders into full frames.
406-func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing.T) {406+func TestAttachmentOutputResizeFrameThenNoopAndDamageAreDifferential(t *testing.T) {
407 screen := vt.NewScreen(4, 2)407 screen := vt.NewScreen(4, 2)
408 screen.Write([]byte("abcd"))408 screen.Write([]byte("abcd"))
409 screen.ClearDamage()409 screen.ClearDamage()
@@ -431,11 +431,43 @@ func TestOutputStateStreamResizeFrameThenNoopAndDamageAreDifferential(t *testing
431 require.Equal(t, resizeOutput.New, damageOutput.Base, "later damage must remain incremental")431 require.Equal(t, resizeOutput.New, damageOutput.Base, "later damage must remain incremental")
432 }432 }
433 433
434-func TestOutputStateStreamDefaultsAndNormalizesWindow(t *testing.T) {434+func TestAttachmentOutputRebaseRetiresAttachmentState(t *testing.T) {
435- require.Equal(t, uint64(8), newOutputStateStream().maxOutstanding)435+ output := newOutputStateStream()
436- require.Equal(t, uint64(8), newOutputStateStream(0).maxOutstanding)436+ output.next = 3
437- require.Equal(t, uint64(8), newOutputStateStream(9).maxOutstanding)437+ output.acked = 2
438- require.Equal(t, uint64(1), newOutputStateStream(1).maxOutstanding)438+ output.lastCursor = cursorOut{valid: true, row: 2, col: 3}
439+ output.lastRoutePosition = ports.RoutePosition{ActiveTabID: "tab"}
440+
441+ output.rebaseAttachment()
442+
443+ require.Equal(t, uint64(2), output.epoch)
444+ require.Zero(t, output.next)
445+ require.Zero(t, output.acked)
446+ require.True(t, output.forceSnapshot)
447+ require.Equal(t, cursorOut{valid: true, row: 2, col: 3}, output.lastCursor, "the forced frame will reassert cursor state")
448+ require.Equal(t, ports.RoutePosition{}, output.lastRoutePosition)
449+}
450+
451+func TestAttachmentOutputDefaultsAndNormalizesWindow(t *testing.T) {
452+ for _, tt := range []struct {
453+ name string
454+ window []uint8
455+ want uint64
456+ }{
457+ {name: "omitted", want: 8},
458+ {name: "zero", window: []uint8{0}, want: 8},
459+ {name: "oversized", window: []uint8{9}, want: 8},
460+ {name: "exact window", window: []uint8{1}, want: 1},
461+ } {
462+ t.Run(tt.name, func(t *testing.T) {
463+ require.Equal(t, tt.want, newOutputStateStream(tt.window...).maxOutstanding)
464+ })
465+ }
466+
467+ output := newOutputStateStream()
468+ output.setWindow(2)
469+ require.Equal(t, uint64(2), output.maxOutstanding)
470+ require.Equal(t, uint64(2), output.maxOutstandingAtomic.Load())
439 }471 }
440 472
441 func TestNormalizeOutputWindow(t *testing.T) {473 func TestNormalizeOutputWindow(t *testing.T) {
M internal/usecase/daemon/client.go +4 -9
@@ -35,7 +35,7 @@ type appliedTheme struct {
3535 type attachedClient struct {
3636 tr ports.Transport
3737 transportIncarnation uint64
38- output *outputStateStream
38+ output *attachmentOutput
3939 overlays *overlayRuntime
4040 overlayOnce sync.Once
4141 clientID [16]byte
@@ -90,8 +90,6 @@ type attachedClient struct {
9090 themeMu sync.Mutex
9191 clientTheme themeui.Theme
9292 appliedTheme appliedTheme
93- lastCursor cursorOut
94- lastRoutePosition ports.RoutePosition
9593 renderStages renderStageHooks // optional render and handoff observability hooks
9694 linkMu sync.Mutex
9795 sendMu sync.Mutex
@@ -421,13 +419,10 @@ func (ac *attachedClient) ackOutputState(epoch, state uint64) {
421419 // rebaseOutput resets only this attachment's output representations. Callers
422420 // hold sendMu (or the activation barrier).
423421 func (ac *attachedClient) rebaseOutput() {
424- if ac == nil {
422+ if ac == nil || ac.output == nil {
425423 return
426424 }
427- if ac.output != nil {
428- ac.output.rebase()
429- }
430- ac.lastRoutePosition = ports.RoutePosition{}
425+ ac.output.rebaseAttachment()
431426 }
432427
433428 var errTransportReplaced = errors.New("client transport was replaced")
@@ -791,7 +786,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *ren
791786 }
792787 },
793788 ackReadyFor: func(attached *attachedClient) bool {
794- // outputStateStream publishes capacity atomically. Do not take
789+ // attachmentOutput publishes capacity atomically. Do not take
795790 // attached.sendMu here: a slow transport may be holding it for an
796791 // in-flight Send, and that peer must not gate healthy attachments.
797792 return attached == nil ||
@@ -35,7 +35,7 @@ type appliedTheme struct {
35 type attachedClient struct {35 type attachedClient struct {
36 tr ports.Transport36 tr ports.Transport
37 transportIncarnation uint6437 transportIncarnation uint64
38- output *outputStateStream38+ output *attachmentOutput
39 overlays *overlayRuntime39 overlays *overlayRuntime
40 overlayOnce sync.Once40 overlayOnce sync.Once
41 clientID [16]byte41 clientID [16]byte
@@ -90,8 +90,6 @@ type attachedClient struct {
90 themeMu sync.Mutex90 themeMu sync.Mutex
91 clientTheme themeui.Theme91 clientTheme themeui.Theme
92 appliedTheme appliedTheme92 appliedTheme appliedTheme
93- lastCursor cursorOut
94- lastRoutePosition ports.RoutePosition
95 renderStages renderStageHooks // optional render and handoff observability hooks93 renderStages renderStageHooks // optional render and handoff observability hooks
96 linkMu sync.Mutex94 linkMu sync.Mutex
97 sendMu sync.Mutex95 sendMu sync.Mutex
@@ -421,13 +419,10 @@ func (ac *attachedClient) ackOutputState(epoch, state uint64) {
421 // rebaseOutput resets only this attachment's output representations. Callers419 // rebaseOutput resets only this attachment's output representations. Callers
422 // hold sendMu (or the activation barrier).420 // hold sendMu (or the activation barrier).
423 func (ac *attachedClient) rebaseOutput() {421 func (ac *attachedClient) rebaseOutput() {
424- if ac == nil {422+ if ac == nil || ac.output == nil {
425 return423 return
426 }424 }
427- if ac.output != nil {425+ ac.output.rebaseAttachment()
428- ac.output.rebase()
429- }
430- ac.lastRoutePosition = ports.RoutePosition{}
431 }426 }
432 427
433 var errTransportReplaced = errors.New("client transport was replaced")428 var errTransportReplaced = errors.New("client transport was replaced")
@@ -791,7 +786,7 @@ func (d *Daemon) ensureAttachmentRenderCoordinatorPrelocked(entry *session) *ren
791 }786 }
792 },787 },
793 ackReadyFor: func(attached *attachedClient) bool {788 ackReadyFor: func(attached *attachedClient) bool {
794- // outputStateStream publishes capacity atomically. Do not take789+ // attachmentOutput publishes capacity atomically. Do not take
795 // attached.sendMu here: a slow transport may be holding it for an790 // attached.sendMu here: a slow transport may be holding it for an
796 // in-flight Send, and that peer must not gate healthy attachments.791 // in-flight Send, and that peer must not gate healthy attachments.
797 return attached == nil ||792 return attached == nil ||
M internal/usecase/daemon/render_coordinator_types.go +1 -1
@@ -103,7 +103,7 @@ type renderCoordinatorOptions struct {
103103 // wake composes a coalesced render request.
104104 wake func(renderWake)
105105 // ackReady reports whether the attachment may compose another output
106- // state (the outputStateStream window has capacity). It is retained for
106+ // state (the attachmentOutput window has capacity). It is retained for
107107 // coordinator-only tests and headless callers; production uses ackReadyFor
108108 // so one attachment cannot gate another.
109109 ackReady func() bool
@@ -103,7 +103,7 @@ type renderCoordinatorOptions struct {
103 // wake composes a coalesced render request.103 // wake composes a coalesced render request.
104 wake func(renderWake)104 wake func(renderWake)
105 // ackReady reports whether the attachment may compose another output105 // ackReady reports whether the attachment may compose another output
106- // state (the outputStateStream window has capacity). It is retained for106+ // state (the attachmentOutput window has capacity). It is retained for
107 // coordinator-only tests and headless callers; production uses ackReadyFor107 // coordinator-only tests and headless callers; production uses ackReadyFor
108 // so one attachment cannot gate another.108 // so one attachment cannot gate another.
109 ackReady func() bool109 ackReady func() bool
M internal/usecase/daemon/render_pipeline.go +10 -50
@@ -2,7 +2,6 @@ package daemon
22
33 import (
44 "errors"
5- "strconv"
65
76 renderer "github.com/bnema/vev-vt"
87 "github.com/bnema/vev/internal/domain"
@@ -379,37 +378,6 @@ func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles {
379378 }
380379 }
381380
382-type cursorCandidate struct {
383- data []byte
384- next cursorOut
385-}
386-
387-func (ac *attachedClient) prepareCursorTail(desired cursorOut, force bool) cursorCandidate {
388- candidate := cursorCandidate{next: desired}
389- candidate.next.valid = true
390- prev := ac.lastCursor
391- changed := force || !prev.valid || prev.hidden != desired.hidden || prev.row != desired.row || prev.col != desired.col || prev.style != desired.style || prev.hasStyle != desired.hasStyle
392- if !changed {
393- return candidate
394- }
395- if desired.hidden {
396- candidate.data = []byte("\x1b[?25l")
397- return candidate
398- }
399- candidate.data = append(candidate.data, "\x1b["...)
400- candidate.data = strconv.AppendInt(candidate.data, int64(desired.row+1), 10)
401- candidate.data = append(candidate.data, ';')
402- candidate.data = strconv.AppendInt(candidate.data, int64(desired.col+1), 10)
403- candidate.data = append(candidate.data, 'H')
404- if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
405- candidate.data = append(candidate.data, "\x1b["...)
406- candidate.data = strconv.AppendInt(candidate.data, int64(desired.style), 10)
407- candidate.data = append(candidate.data, " q"...)
408- }
409- candidate.data = append(candidate.data, "\x1b[?25h"...)
410- return candidate
411-}
412-
413381 // commitDamageReceipts acknowledges only snapshots that reached the client.
414382 // sendMu serializes attachment transactions; panes are locked individually so
415383 // no pane lock is held while another is acquired. A stale generation is
@@ -468,17 +436,10 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
468436 }
469437 }
470438 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
471- var (
472- preparedANSI *preparedOutput
473- err error
474- cursor cursorCandidate
475- data []byte
476- )
477- preparedANSI, err = ac.output.prepare(composed.frame, composed.damage, composed.reset)
478- if err == nil {
479- cursor = ac.prepareCursorTail(composed.cursor, len(preparedANSI.data) > 0)
480- data = append([]byte(nil), preparedANSI.data...)
481- data = append(data, cursor.data...)
439+ prepared, err := ac.output.prepareFrame(composed.frame, composed.damage, composed.reset, composed.cursor)
440+ var data []byte
441+ if prepared != nil {
442+ data = prepared.data
482443 }
483444 endDiff(0, err == nil)
484445 if err != nil {
@@ -526,7 +487,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
526487 }
527488 }
528489 if sendErr == nil {
529- sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)
490+ sendErr = prepared.send(ac.echoAck.Load(), send)
530491 }
531492 if marks.attachmentEffect != nil && interruptible {
532493 if sendErr != nil {
@@ -535,7 +496,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
535496 marks.attachmentEffect.endTransportSend()
536497 }
537498 }
538- emitted := sendErr == nil && preparedANSI.sent
499+ emitted := sendErr == nil && prepared.sent()
539500 endEmit(uint64(len(data)), emitted)
540501 if sendErr == nil && !emitted {
541502 ac.sendMu.Unlock()
@@ -544,8 +505,8 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
544505 }
545506 if sendErr == nil {
546507 if len(data) == 0 {
547- preparedANSI.commitNoSend()
548- if !preparedANSI.sent {
508+ prepared.commitNoSend()
509+ if !prepared.sent() {
549510 ac.sendMu.Unlock()
550511 return true
551512 }
@@ -554,7 +515,6 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
554515 // succeed. A cross-session transition may publish concurrently, but its
555516 // mandatory first-paint rebase waits for sendMu and therefore follows this
556517 // completed output transaction.
557- ac.lastCursor = cursor.next
558518 ac.pipelineScratch = ac.pipelineCache
559519 ac.pipelineCache = composed.cache
560520
@@ -566,7 +526,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
566526 ac.renderStages.emit()
567527 }
568528
569- if position.ActiveTabID != "" && position != ac.lastRoutePosition {
529+ if position.ActiveTabID != "" && position != ac.output.lastRoutePosition {
570530 payload, positionMarshalErr := ports.MarshalRoutePosition(position)
571531 if positionMarshalErr != nil {
572532 d.log.Error("marshal route position", "err", positionMarshalErr)
@@ -598,7 +558,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
598558 marks.attachmentEffect.endTransportSend()
599559 }
600560 if sendErr == nil {
601- ac.lastRoutePosition = position
561+ ac.output.lastRoutePosition = position
602562 }
603563 }
604564 }
@@ -2,7 +2,6 @@ package daemon
2 2
3 import (3 import (
4 "errors"4 "errors"
5- "strconv"
6 5
7 renderer "github.com/bnema/vev-vt"6 renderer "github.com/bnema/vev-vt"
8 "github.com/bnema/vev/internal/domain"7 "github.com/bnema/vev/internal/domain"
@@ -379,37 +378,6 @@ func noticeStylesFrom(styles themeui.Styles) ui.NoticeStyles {
379 }378 }
380 }379 }
381 380
382-type cursorCandidate struct {
383- data []byte
384- next cursorOut
385-}
386-
387-func (ac *attachedClient) prepareCursorTail(desired cursorOut, force bool) cursorCandidate {
388- candidate := cursorCandidate{next: desired}
389- candidate.next.valid = true
390- prev := ac.lastCursor
391- changed := force || !prev.valid || prev.hidden != desired.hidden || prev.row != desired.row || prev.col != desired.col || prev.style != desired.style || prev.hasStyle != desired.hasStyle
392- if !changed {
393- return candidate
394- }
395- if desired.hidden {
396- candidate.data = []byte("\x1b[?25l")
397- return candidate
398- }
399- candidate.data = append(candidate.data, "\x1b["...)
400- candidate.data = strconv.AppendInt(candidate.data, int64(desired.row+1), 10)
401- candidate.data = append(candidate.data, ';')
402- candidate.data = strconv.AppendInt(candidate.data, int64(desired.col+1), 10)
403- candidate.data = append(candidate.data, 'H')
404- if !prev.valid || prev.hidden || prev.style != desired.style || prev.hasStyle != desired.hasStyle {
405- candidate.data = append(candidate.data, "\x1b["...)
406- candidate.data = strconv.AppendInt(candidate.data, int64(desired.style), 10)
407- candidate.data = append(candidate.data, " q"...)
408- }
409- candidate.data = append(candidate.data, "\x1b[?25h"...)
410- return candidate
411-}
412-
413 // commitDamageReceipts acknowledges only snapshots that reached the client.381 // commitDamageReceipts acknowledges only snapshots that reached the client.
414 // sendMu serializes attachment transactions; panes are locked individually so382 // sendMu serializes attachment transactions; panes are locked individually so
415 // no pane lock is held while another is acquired. A stale generation is383 // no pane lock is held while another is acquired. A stale generation is
@@ -468,17 +436,10 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
468 }436 }
469 }437 }
470 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)438 endDiff := marks.span(ports.RuntimeDiffStart, ports.RuntimeDiffEnd, 0)
471- var (439+ prepared, err := ac.output.prepareFrame(composed.frame, composed.damage, composed.reset, composed.cursor)
472- preparedANSI *preparedOutput440+ var data []byte
473- err error441+ if prepared != nil {
474- cursor cursorCandidate442+ data = prepared.data
475- data []byte
476- )
477- preparedANSI, err = ac.output.prepare(composed.frame, composed.damage, composed.reset)
478- if err == nil {
479- cursor = ac.prepareCursorTail(composed.cursor, len(preparedANSI.data) > 0)
480- data = append([]byte(nil), preparedANSI.data...)
481- data = append(data, cursor.data...)
482 }443 }
483 endDiff(0, err == nil)444 endDiff(0, err == nil)
484 if err != nil {445 if err != nil {
@@ -526,7 +487,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
526 }487 }
527 }488 }
528 if sendErr == nil {489 if sendErr == nil {
529- sendErr = preparedANSI.send(data, ac.echoAck.Load(), send)490+ sendErr = prepared.send(ac.echoAck.Load(), send)
530 }491 }
531 if marks.attachmentEffect != nil && interruptible {492 if marks.attachmentEffect != nil && interruptible {
532 if sendErr != nil {493 if sendErr != nil {
@@ -535,7 +496,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
535 marks.attachmentEffect.endTransportSend()496 marks.attachmentEffect.endTransportSend()
536 }497 }
537 }498 }
538- emitted := sendErr == nil && preparedANSI.sent499+ emitted := sendErr == nil && prepared.sent()
539 endEmit(uint64(len(data)), emitted)500 endEmit(uint64(len(data)), emitted)
540 if sendErr == nil && !emitted {501 if sendErr == nil && !emitted {
541 ac.sendMu.Unlock()502 ac.sendMu.Unlock()
@@ -544,8 +505,8 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
544 }505 }
545 if sendErr == nil {506 if sendErr == nil {
546 if len(data) == 0 {507 if len(data) == 0 {
547- preparedANSI.commitNoSend()508+ prepared.commitNoSend()
548- if !preparedANSI.sent {509+ if !prepared.sent() {
549 ac.sendMu.Unlock()510 ac.sendMu.Unlock()
550 return true511 return true
551 }512 }
@@ -554,7 +515,6 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
554 // succeed. A cross-session transition may publish concurrently, but its515 // succeed. A cross-session transition may publish concurrently, but its
555 // mandatory first-paint rebase waits for sendMu and therefore follows this516 // mandatory first-paint rebase waits for sendMu and therefore follows this
556 // completed output transaction.517 // completed output transaction.
557- ac.lastCursor = cursor.next
558 ac.pipelineScratch = ac.pipelineCache518 ac.pipelineScratch = ac.pipelineCache
559 ac.pipelineCache = composed.cache519 ac.pipelineCache = composed.cache
560 520
@@ -566,7 +526,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
566 ac.renderStages.emit()526 ac.renderStages.emit()
567 }527 }
568 528
569- if position.ActiveTabID != "" && position != ac.lastRoutePosition {529+ if position.ActiveTabID != "" && position != ac.output.lastRoutePosition {
570 payload, positionMarshalErr := ports.MarshalRoutePosition(position)530 payload, positionMarshalErr := ports.MarshalRoutePosition(position)
571 if positionMarshalErr != nil {531 if positionMarshalErr != nil {
572 d.log.Error("marshal route position", "err", positionMarshalErr)532 d.log.Error("marshal route position", "err", positionMarshalErr)
@@ -598,7 +558,7 @@ func (d *Daemon) emitFrame(entry *session, ac *attachedClient, state *capturedRe
598 marks.attachmentEffect.endTransportSend()558 marks.attachmentEffect.endTransportSend()
599 }559 }
600 if sendErr == nil {560 if sendErr == nil {
601- ac.lastRoutePosition = position561+ ac.output.lastRoutePosition = position
602 }562 }
603 }563 }
604 }564 }
M internal/usecase/daemon/render_pipeline_test.go +17 -12
@@ -213,12 +213,12 @@ func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
213213 }
214214
215215 func TestCursorCandidateDoesNotPublishDuringPreparation(t *testing.T) {
216- ac := &attachedClient{lastCursor: cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}}
216+ output := &attachmentOutput{lastCursor: cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}}
217217 desired := cursorOut{row: 3, col: 4, style: 2, hasStyle: true}
218218
219- candidate := ac.prepareCursorTail(desired, false)
219+ candidate := output.prepareCursorTail(desired, false)
220220
221- require.Equal(t, cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}, ac.lastCursor)
221+ require.Equal(t, cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}, output.lastCursor)
222222 require.NotEmpty(t, candidate.data)
223223 require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, candidate.next)
224224 }
@@ -227,7 +227,7 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
227227 d, sess, ac, sends := newManualSessionWithPTYs(t)
228228 healthy := ac.transport()
229229 beforeCursor := cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}
230- ac.lastCursor = beforeCursor
230+ ac.output.lastCursor = beforeCursor
231231 ac.replaceTransport(cacheFailTransport{})
232232 state := cacheState("failed", 1)
233233 state.attachment = ac
@@ -237,7 +237,7 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
237237 ac.sendMu.Lock()
238238 require.True(t, d.emitFrame(sess, ac, &state, composed))
239239
240- require.Equal(t, beforeCursor, ac.lastCursor)
240+ require.Equal(t, beforeCursor, ac.output.lastCursor)
241241 require.Zero(t, ac.output.next)
242242 probe, err := ac.output.renderer.Prepare(composed.frame, nil, false)
243243 require.NoError(t, err)
@@ -251,7 +251,7 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
251251 state.attachment = ac
252252 ac.sendMu.Lock()
253253 require.True(t, d.emitFrame(sess, ac, &state, composed))
254- require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, ac.lastCursor)
254+ require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, ac.output.lastCursor)
255255 require.Equal(t, uint64(1), ac.output.next)
256256 out, err := ports.UnmarshalOutput((<-sends).Payload)
257257 require.NoError(t, err)
@@ -276,7 +276,7 @@ func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T)
276276 p.mu.Unlock()
277277
278278 beforeNext := ac.output.next
279- beforeCursor := ac.lastCursor
279+ beforeCursor := ac.output.lastCursor
280280 state.receipts = []damageReceipt{{pane: p, generation: capture.Generation}}
281281 noByte := composedRenderFrame{
282282 frame: initial.frame,
@@ -288,7 +288,7 @@ func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T)
288288 require.True(t, d.emitFrame(sess, ac, &state, noByte))
289289
290290 require.Equal(t, beforeNext, ac.output.next)
291- require.Equal(t, beforeCursor, ac.lastCursor)
291+ require.Equal(t, beforeCursor, ac.output.lastCursor)
292292 require.Equal(t, "no-byte-committed", ac.pipelineCache.layoutFingerprint)
293293 p.mu.Lock()
294294 require.Empty(t, p.screen.Damage())
@@ -330,15 +330,20 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
330330 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
331331
332332 stream := newOutputStateStream()
333- prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)
333+ prepared, err := stream.prepareFrame(composed.frame, composed.damage, composed.reset, composed.cursor)
334334 require.NoError(t, err)
335- terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).prepareCursorTail(composed.cursor, true).data...)
335+ var outputFrame ports.Frame
336+ require.NoError(t, prepared.send(0, func(frame ports.Frame) error {
337+ outputFrame = frame
338+ return nil
339+ }))
340+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
341+ require.NoError(t, err)
342+ terminalBytes := output.Data
336343 require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
337344 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
338345 client.Write(terminalBytes)
339346 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
340- prepared.draw.Commit()
341- stream.forceSnapshot = false
342347 again, err := stream.renderer.Draw(composed.frame, nil)
343348 require.NoError(t, err)
344349 require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
@@ -213,12 +213,12 @@ func TestProductionRenderUsesOnlyTypedComposeEntryPoint(t *testing.T) {
213 }213 }
214 214
215 func TestCursorCandidateDoesNotPublishDuringPreparation(t *testing.T) {215 func TestCursorCandidateDoesNotPublishDuringPreparation(t *testing.T) {
216- ac := &attachedClient{lastCursor: cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}}216+ output := &attachmentOutput{lastCursor: cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}}
217 desired := cursorOut{row: 3, col: 4, style: 2, hasStyle: true}217 desired := cursorOut{row: 3, col: 4, style: 2, hasStyle: true}
218 218
219- candidate := ac.prepareCursorTail(desired, false)219+ candidate := output.prepareCursorTail(desired, false)
220 220
221- require.Equal(t, cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}, ac.lastCursor)221+ require.Equal(t, cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}, output.lastCursor)
222 require.NotEmpty(t, candidate.data)222 require.NotEmpty(t, candidate.data)
223 require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, candidate.next)223 require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, candidate.next)
224 }224 }
@@ -227,7 +227,7 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
227 d, sess, ac, sends := newManualSessionWithPTYs(t)227 d, sess, ac, sends := newManualSessionWithPTYs(t)
228 healthy := ac.transport()228 healthy := ac.transport()
229 beforeCursor := cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}229 beforeCursor := cursorOut{valid: true, row: 1, col: 2, style: 1, hasStyle: true}
230- ac.lastCursor = beforeCursor230+ ac.output.lastCursor = beforeCursor
231 ac.replaceTransport(cacheFailTransport{})231 ac.replaceTransport(cacheFailTransport{})
232 state := cacheState("failed", 1)232 state := cacheState("failed", 1)
233 state.attachment = ac233 state.attachment = ac
@@ -237,7 +237,7 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
237 ac.sendMu.Lock()237 ac.sendMu.Lock()
238 require.True(t, d.emitFrame(sess, ac, &state, composed))238 require.True(t, d.emitFrame(sess, ac, &state, composed))
239 239
240- require.Equal(t, beforeCursor, ac.lastCursor)240+ require.Equal(t, beforeCursor, ac.output.lastCursor)
241 require.Zero(t, ac.output.next)241 require.Zero(t, ac.output.next)
242 probe, err := ac.output.renderer.Prepare(composed.frame, nil, false)242 probe, err := ac.output.renderer.Prepare(composed.frame, nil, false)
243 require.NoError(t, err)243 require.NoError(t, err)
@@ -251,7 +251,7 @@ func TestEmitFrameFailedSendDoesNotPublishCursorOrOutputState(t *testing.T) {
251 state.attachment = ac251 state.attachment = ac
252 ac.sendMu.Lock()252 ac.sendMu.Lock()
253 require.True(t, d.emitFrame(sess, ac, &state, composed))253 require.True(t, d.emitFrame(sess, ac, &state, composed))
254- require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, ac.lastCursor)254+ require.Equal(t, cursorOut{valid: true, row: 3, col: 4, style: 2, hasStyle: true}, ac.output.lastCursor)
255 require.Equal(t, uint64(1), ac.output.next)255 require.Equal(t, uint64(1), ac.output.next)
256 out, err := ports.UnmarshalOutput((<-sends).Payload)256 out, err := ports.UnmarshalOutput((<-sends).Payload)
257 require.NoError(t, err)257 require.NoError(t, err)
@@ -276,7 +276,7 @@ func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T)
276 p.mu.Unlock()276 p.mu.Unlock()
277 277
278 beforeNext := ac.output.next278 beforeNext := ac.output.next
279- beforeCursor := ac.lastCursor279+ beforeCursor := ac.output.lastCursor
280 state.receipts = []damageReceipt{{pane: p, generation: capture.Generation}}280 state.receipts = []damageReceipt{{pane: p, generation: capture.Generation}}
281 noByte := composedRenderFrame{281 noByte := composedRenderFrame{
282 frame: initial.frame,282 frame: initial.frame,
@@ -288,7 +288,7 @@ func TestEmitFrameNoByteSuccessCommitsTransactionWithoutStateFrame(t *testing.T)
288 require.True(t, d.emitFrame(sess, ac, &state, noByte))288 require.True(t, d.emitFrame(sess, ac, &state, noByte))
289 289
290 require.Equal(t, beforeNext, ac.output.next)290 require.Equal(t, beforeNext, ac.output.next)
291- require.Equal(t, beforeCursor, ac.lastCursor)291+ require.Equal(t, beforeCursor, ac.output.lastCursor)
292 require.Equal(t, "no-byte-committed", ac.pipelineCache.layoutFingerprint)292 require.Equal(t, "no-byte-committed", ac.pipelineCache.layoutFingerprint)
293 p.mu.Lock()293 p.mu.Lock()
294 require.Empty(t, p.screen.Damage())294 require.Empty(t, p.screen.Damage())
@@ -330,15 +330,20 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
330 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")330 require.True(t, composed.cursor.hidden, "overlay owns cursor visibility")
331 331
332 stream := newOutputStateStream()332 stream := newOutputStateStream()
333- prepared, err := stream.prepare(composed.frame, composed.damage, composed.reset)333+ prepared, err := stream.prepareFrame(composed.frame, composed.damage, composed.reset, composed.cursor)
334 require.NoError(t, err)334 require.NoError(t, err)
335- terminalBytes := append(append([]byte(nil), prepared.data...), (&attachedClient{}).prepareCursorTail(composed.cursor, true).data...)335+ var outputFrame ports.Frame
336+ require.NoError(t, prepared.send(0, func(frame ports.Frame) error {
337+ outputFrame = frame
338+ return nil
339+ }))
340+ output, err := ports.UnmarshalOutput(outputFrame.Payload)
341+ require.NoError(t, err)
342+ terminalBytes := output.Data
336 require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))343 require.Equal(t, "\x1b[1;1H\x1b[0;7m tab \x1b[0m R\x1b[2;1HAAAAAAAAAAAA\x1b[3;1HBBB┌─fl─┐BBB\x1b[4;1H───Prompt───\x1b[5;1HPROMPT\x1b[K\x1b[B\x1b[2K\x1b[7;1H\x1b[0;7m sess \x1b[0m B\x1b[0m\x1b[?25l", string(terminalBytes))
337 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)344 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
338 client.Write(terminalBytes)345 client.Write(terminalBytes)
339 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))346 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
340- prepared.draw.Commit()
341- stream.forceSnapshot = false
342 again, err := stream.renderer.Draw(composed.frame, nil)347 again, err := stream.renderer.Draw(composed.frame, nil)
343 require.NoError(t, err)348 require.NoError(t, err)
344 require.Empty(t, again, "renderer shadow must exactly equal the composed frame")349 require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
M internal/usecase/daemon/render_test.go +2 -2
@@ -1085,8 +1085,8 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) {
10851085 want := committed.translate(contentArea.X, contentArea.Y)
10861086 require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1))
10871087 require.Contains(t, string(data), "F")
1088- require.Equal(t, want.Inner.Y, ac.lastCursor.row)
1089- require.Equal(t, want.Inner.X, ac.lastCursor.col)
1088+ require.Equal(t, want.Inner.Y, ac.output.lastCursor.row)
1089+ require.Equal(t, want.Inner.X, ac.output.lastCursor.col)
10901090 require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, want.Inner.X+1))
10911091 }
10921092
@@ -1085,8 +1085,8 @@ func TestPaintAlignsFloatingCursorWithCommittedGeometry(t *testing.T) {
1085 want := committed.translate(contentArea.X, contentArea.Y)1085 want := committed.translate(contentArea.X, contentArea.Y)
1086 require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1))1086 require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, 1))
1087 require.Contains(t, string(data), "F")1087 require.Contains(t, string(data), "F")
1088- require.Equal(t, want.Inner.Y, ac.lastCursor.row)1088+ require.Equal(t, want.Inner.Y, ac.output.lastCursor.row)
1089- require.Equal(t, want.Inner.X, ac.lastCursor.col)1089+ require.Equal(t, want.Inner.X, ac.output.lastCursor.col)
1090 require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, want.Inner.X+1))1090 require.Contains(t, string(data), cursorCSI(want.Inner.Y+1, want.Inner.X+1))
1091 }1091 }
1092 1092
M internal/usecase/daemon/resume.go +1 -2
@@ -638,8 +638,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
638638 // abandoned transport's output chain before binding the replacement so the
639639 // mandatory first paint cannot be blocked by ACKs that died with the link.
640640 ac.rebaseOutput()
641- ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))
642- ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding)
641+ ac.output.setWindow(h.MaxOutputInFlight)
643642 ac.replaceTransport(tr)
644643 ac.setSize(sz)
645644 ac.resumeToken = d.nextResumeTokenLocked()
@@ -638,8 +638,7 @@ func (d *Daemon) resumeParkedLocked(h ports.Hello, tr ports.Transport, sz domain
638 // abandoned transport's output chain before binding the replacement so the638 // abandoned transport's output chain before binding the replacement so the
639 // mandatory first paint cannot be blocked by ACKs that died with the link.639 // mandatory first paint cannot be blocked by ACKs that died with the link.
640 ac.rebaseOutput()640 ac.rebaseOutput()
641- ac.output.maxOutstanding = uint64(normalizeOutputWindow(h.MaxOutputInFlight))641+ ac.output.setWindow(h.MaxOutputInFlight)
642- ac.output.maxOutstandingAtomic.Store(ac.output.maxOutstanding)
643 ac.replaceTransport(tr)642 ac.replaceTransport(tr)
644 ac.setSize(sz)643 ac.setSize(sz)
645 ac.resumeToken = d.nextResumeTokenLocked()644 ac.resumeToken = d.nextResumeTokenLocked()
M internal/usecase/daemon/toast_damage_test.go +1 -1
@@ -124,7 +124,7 @@ func toastDamageState(notices []domain.Notification, damage []renderer.Damage, r
124124 }
125125 }
126126
127-func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) {
127+func replayToastFrame(t *testing.T, stream *attachmentOutput, terminal *vt.Screen, composed composedRenderFrame) {
128128 t.Helper()
129129 data, err := stream.render(composed.frame, composed.damage, composed.reset)
130130 require.NoError(t, err)
@@ -124,7 +124,7 @@ func toastDamageState(notices []domain.Notification, damage []renderer.Damage, r
124 }124 }
125 }125 }
126 126
127-func replayToastFrame(t *testing.T, stream *outputStateStream, terminal *vt.Screen, composed composedRenderFrame) {127+func replayToastFrame(t *testing.T, stream *attachmentOutput, terminal *vt.Screen, composed composedRenderFrame) {
128 t.Helper()128 t.Helper()
129 data, err := stream.render(composed.frame, composed.damage, composed.reset)129 data, err := stream.render(composed.frame, composed.damage, composed.reset)
130 require.NoError(t, err)130 require.NoError(t, err)

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