detached 15846e6

fix(daemon): apply proxy screen review fixes

commit
15846e648b6cef584e71b9ccaffb84bb51b76b35
author
brice · · 2026-08-02 10:48 UTC
parent
4634437
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +144 -83
M internal/usecase/daemon/output_state_stream.go +5 -14
@@ -38,7 +38,7 @@ type preparedOutput struct {
3838 next uint64
3939 data []byte
4040 reset bool
41- completed bool
41+ attempted bool
4242 }
4343
4444 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
@@ -57,10 +57,10 @@ func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Dama
5757 }
5858
5959 func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error {
60- if p.completed {
60+ if p.attempted {
6161 return nil
6262 }
63- p.completed = true
63+ p.attempted = true
6464 base := p.stream.next
6565 if p.reset {
6666 base = 0
@@ -76,10 +76,10 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame
7676 }
7777
7878 func (p *preparedOutput) commitNoSend() {
79- if p.completed {
79+ if p == nil || p.attempted || len(p.data) != 0 {
8080 return
8181 }
82- p.completed = true
82+ p.attempted = true
8383 p.commit()
8484 }
8585
@@ -88,15 +88,6 @@ func (p *preparedOutput) commit() {
8888 p.stream.forceSnapshot = false
8989 }
9090
91-func (s *outputStateStream) frame(data []byte, reset bool, echoAck uint64) ports.Frame {
92- s.next++
93- base := s.next - 1
94- if reset {
95- base = 0
96- }
97- return frameOutputState(data, base, s.next, echoAck)
98-}
99-
10091 func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame {
10192 return frameOutputState(data, 0, 0, echoAck)
10293 }
@@ -38,7 +38,7 @@ type preparedOutput struct {
38 next uint6438 next uint64
39 data []byte39 data []byte
40 reset bool40 reset bool
41- completed bool41+ attempted bool
42 }42 }
43 43
44 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {44 func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Damage, reset bool) (*preparedOutput, error) {
@@ -57,10 +57,10 @@ func (s *outputStateStream) prepare(frame renderer.Frame, damage []renderer.Dama
57 }57 }
58 58
59 func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error {59 func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame) error) error {
60- if p.completed {60+ if p.attempted {
61 return nil61 return nil
62 }62 }
63- p.completed = true63+ p.attempted = true
64 base := p.stream.next64 base := p.stream.next
65 if p.reset {65 if p.reset {
66 base = 066 base = 0
@@ -76,10 +76,10 @@ func (p *preparedOutput) send(data []byte, echoAck uint64, send func(ports.Frame
76 }76 }
77 77
78 func (p *preparedOutput) commitNoSend() {78 func (p *preparedOutput) commitNoSend() {
79- if p.completed {79+ if p == nil || p.attempted || len(p.data) != 0 {
80 return80 return
81 }81 }
82- p.completed = true82+ p.attempted = true
83 p.commit()83 p.commit()
84 }84 }
85 85
@@ -88,15 +88,6 @@ func (p *preparedOutput) commit() {
88 p.stream.forceSnapshot = false88 p.stream.forceSnapshot = false
89 }89 }
90 90
91-func (s *outputStateStream) frame(data []byte, reset bool, echoAck uint64) ports.Frame {
92- s.next++
93- base := s.next - 1
94- if reset {
95- base = 0
96- }
97- return frameOutputState(data, base, s.next, echoAck)
98-}
99-
100 func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame {91 func (s *outputStateStream) sideEffect(data []byte, echoAck uint64) ports.Frame {
101 return frameOutputState(data, 0, 0, echoAck)92 return frameOutputState(data, 0, 0, echoAck)
102 }93 }
M internal/usecase/daemon/output_state_stream_test.go +31 -4
@@ -141,17 +141,44 @@ func (s *outputStateStream) render(frame renderer.Frame, damage []renderer.Damag
141141 if err != nil {
142142 return nil, err
143143 }
144- prepared.commitNoSend()
144+ if len(prepared.data) == 0 {
145+ prepared.commitNoSend()
146+ return nil, nil
147+ }
148+ // This helper tests renderer replay bytes without publishing a wire frame.
149+ // Production state advancement happens only through preparedOutput.send.
150+ prepared.draw.Commit()
151+ s.forceSnapshot = false
145152 return prepared.data, nil
146153 }
147154
155+func outputStateFrame(stream *outputStateStream, data []byte, reset bool, echoAck uint64) ports.Frame {
156+ stream.next++
157+ base := stream.next - 1
158+ if reset {
159+ base = 0
160+ }
161+ return frameOutputState(data, base, stream.next, echoAck)
162+}
163+
148164 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
149165 t.Helper()
150- data, err := stream.render(frame, damage, reset)
151- if err != nil || len(data) == 0 {
166+ prepared, err := stream.prepare(frame, damage, reset)
167+ if err != nil || len(prepared.data) == 0 {
168+ if prepared != nil {
169+ prepared.commitNoSend()
170+ }
171+ return ports.Frame{}, false, err
172+ }
173+ var output ports.Frame
174+ err = prepared.send(prepared.data, echoAck, func(frame ports.Frame) error {
175+ output = frame
176+ return nil
177+ })
178+ if err != nil {
152179 return ports.Frame{}, false, err
153180 }
154- return stream.frame(data, reset, echoAck), true, nil
181+ return output, true, nil
155182 }
156183
157184 // A resize is a state reset, but it must not turn subsequent idle or damaged
@@ -141,17 +141,44 @@ func (s *outputStateStream) render(frame renderer.Frame, damage []renderer.Damag
141 if err != nil {141 if err != nil {
142 return nil, err142 return nil, err
143 }143 }
144- prepared.commitNoSend()144+ if len(prepared.data) == 0 {
145+ prepared.commitNoSend()
146+ return nil, nil
147+ }
148+ // This helper tests renderer replay bytes without publishing a wire frame.
149+ // Production state advancement happens only through preparedOutput.send.
150+ prepared.draw.Commit()
151+ s.forceSnapshot = false
145 return prepared.data, nil152 return prepared.data, nil
146 }153 }
147 154
155+func outputStateFrame(stream *outputStateStream, data []byte, reset bool, echoAck uint64) ports.Frame {
156+ stream.next++
157+ base := stream.next - 1
158+ if reset {
159+ base = 0
160+ }
161+ return frameOutputState(data, base, stream.next, echoAck)
162+}
163+
148 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {164 func drawOutputState(t *testing.T, stream *outputStateStream, frame renderer.Frame, damage []renderer.Damage, reset bool, echoAck uint64) (ports.Frame, bool, error) {
149 t.Helper()165 t.Helper()
150- data, err := stream.render(frame, damage, reset)166+ prepared, err := stream.prepare(frame, damage, reset)
151- if err != nil || len(data) == 0 {167+ if err != nil || len(prepared.data) == 0 {
168+ if prepared != nil {
169+ prepared.commitNoSend()
170+ }
171+ return ports.Frame{}, false, err
172+ }
173+ var output ports.Frame
174+ err = prepared.send(prepared.data, echoAck, func(frame ports.Frame) error {
175+ output = frame
176+ return nil
177+ })
178+ if err != nil {
152 return ports.Frame{}, false, err179 return ports.Frame{}, false, err
153 }180 }
154- return stream.frame(data, reset, echoAck), true, nil181+ return output, true, nil
155 }182 }
156 183
157 // A resize is a state reset, but it must not turn subsequent idle or damaged184 // A resize is a state reset, but it must not turn subsequent idle or damaged
M internal/usecase/daemon/proxy_output.go +1 -2
@@ -32,14 +32,13 @@ func (p *proxySession) applyScreenUpdateForGeneration(generation uint64, update
3232 if update.NewStateNum <= p.appliedState {
3333 return 0, p.requestScreenResetLocked(), false
3434 }
35- before := p.screen.cursorOut
3635 if err := p.screen.Apply(update); err != nil {
3736 return 0, p.requestScreenResetLocked(), false
3837 }
3938 p.appliedState = update.NewStateNum
4039 p.screenReady = true
4140 p.resetRequested = false
42- return update.NewStateNum, false, update.Kind == ports.ScreenUpdateSnapshot || before != update.Cursor || len(update.Spans) != 0
41+ return update.NewStateNum, false, true
4342 }
4443
4544 if !p.screenReady || p.resetRequested || update.BaseStateNum != p.appliedState || update.NewStateNum != update.BaseStateNum+1 {
@@ -32,14 +32,13 @@ func (p *proxySession) applyScreenUpdateForGeneration(generation uint64, update
32 if update.NewStateNum <= p.appliedState {32 if update.NewStateNum <= p.appliedState {
33 return 0, p.requestScreenResetLocked(), false33 return 0, p.requestScreenResetLocked(), false
34 }34 }
35- before := p.screen.cursorOut
36 if err := p.screen.Apply(update); err != nil {35 if err := p.screen.Apply(update); err != nil {
37 return 0, p.requestScreenResetLocked(), false36 return 0, p.requestScreenResetLocked(), false
38 }37 }
39 p.appliedState = update.NewStateNum38 p.appliedState = update.NewStateNum
40 p.screenReady = true39 p.screenReady = true
41 p.resetRequested = false40 p.resetRequested = false
42- return update.NewStateNum, false, update.Kind == ports.ScreenUpdateSnapshot || before != update.Cursor || len(update.Spans) != 041+ return update.NewStateNum, false, true
43 }42 }
44 43
45 if !p.screenReady || p.resetRequested || update.BaseStateNum != p.appliedState || update.NewStateNum != update.BaseStateNum+1 {44 if !p.screenReady || p.resetRequested || update.BaseStateNum != p.appliedState || update.NewStateNum != update.BaseStateNum+1 {
M internal/usecase/daemon/proxy_output_test.go +19 -14
@@ -4,6 +4,7 @@ import (
44 "context"
55 "io"
66 "log/slog"
7+ "maps"
78 "sync"
89 "sync/atomic"
910 "testing"
@@ -182,11 +183,7 @@ func newProxyTestDaemon(t *testing.T, factory ports.RemoteDialerFactory, clk por
182183 func proxySessionSnapshot(d *Daemon) map[domain.SessionID]attachmentSession {
183184 d.mu.Lock()
184185 defer d.mu.Unlock()
185- clone := make(map[domain.SessionID]attachmentSession, len(d.sessions))
186- for id, entry := range d.sessions {
187- clone[id] = entry
188- }
189- return clone
186+ return maps.Clone(d.sessions)
190187 }
191188
192189 func proxyFactoryFor(t *testing.T, key domain.RemoteSessionKey, transports ...ports.Transport) ports.RemoteDialerFactory {
@@ -246,7 +243,7 @@ func TestProxyHandshakePublishesOnlyAfterWelcomeAndMetadata(t *testing.T) {
246243 require.Equal(t, ports.IntentAttach, hello.Intent)
247244 require.True(t, hello.Proxied)
248245 require.Equal(t, key.Name, hello.Name)
249- require.Equal(t, domain.Size{Cols: 80, Rows: 22}, hello.Size)
246+ require.Equal(t, contentSize(domain.Size{Cols: 80, Rows: 24}, false), hello.Size)
250247 require.Equal(t, uint8(maxUnackedOutputStates), hello.MaxOutputInFlight)
251248 require.Empty(t, proxySessionSnapshot(d), "Hello alone must not publish a proxy")
252249
@@ -457,11 +454,9 @@ func TestProxySenderSerializesAllTransportWrites(t *testing.T) {
457454 var wg sync.WaitGroup
458455 errs := make(chan error, 8)
459456 for range 8 {
460- wg.Add(1)
461- go func() {
462- defer wg.Done()
457+ wg.Go(func() {
463458 errs <- proxy.sendCurrent(ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})})
464- }()
459+ })
465460 }
466461 // Once the first sender enters Send, the others must remain behind sendMu.
467462 awaitTestCompletion(t, entered, "proxy sender did not enter transport")
@@ -669,6 +664,15 @@ func screenSnapshotForProxy(t *testing.T, proxy *proxySession, state uint64) por
669664 return snapshot
670665 }
671666
667+func TestProxyScreenSnapshotReportsChanged(t *testing.T) {
668+ _, proxy, _, generation := newProxyOutputSession(t)
669+ snapshot := screenSnapshotForProxy(t, proxy, 1)
670+ ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, snapshot)
671+ require.Equal(t, uint64(1), ack)
672+ require.False(t, reset)
673+ require.True(t, changed, "every accepted snapshot invalidates the composed frame")
674+}
675+
672676 func TestProxyScreenUpdate(t *testing.T) {
673677 t.Run("initial snapshot", func(t *testing.T) {
674678 d, proxy, transport, generation := newProxyOutputSession(t)
@@ -812,19 +816,20 @@ func TestProxyScreenUpdate(t *testing.T) {
812816 }()
813817 awaitTestCompletion(t, entered, "screen ACK sender did not enter transport")
814818
815- unlocked := make(chan struct{})
819+ unlocked := make(chan ports.Transport, 1)
816820 go func() {
817821 proxy.mu.Lock()
818822 currentTransport := proxy.transport
819823 proxy.mu.Unlock()
820- require.Same(t, transport, currentTransport)
821- close(unlocked)
824+ unlocked <- currentTransport
822825 }()
826+ var currentTransport ports.Transport
823827 select {
824- case <-unlocked:
828+ case currentTransport = <-unlocked:
825829 case <-time.After(time.Second):
826830 t.Fatal("proxy.mu was held while screen send was blocked")
827831 }
832+ require.Same(t, transport, currentTransport)
828833 require.NoError(t, transport.Close())
829834 require.Error(t, awaitTestValue(t, result, "blocked screen send did not return"))
830835 })
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "io"5 "io"
6 "log/slog"6 "log/slog"
7+ "maps"
7 "sync"8 "sync"
8 "sync/atomic"9 "sync/atomic"
9 "testing"10 "testing"
@@ -182,11 +183,7 @@ func newProxyTestDaemon(t *testing.T, factory ports.RemoteDialerFactory, clk por
182 func proxySessionSnapshot(d *Daemon) map[domain.SessionID]attachmentSession {183 func proxySessionSnapshot(d *Daemon) map[domain.SessionID]attachmentSession {
183 d.mu.Lock()184 d.mu.Lock()
184 defer d.mu.Unlock()185 defer d.mu.Unlock()
185- clone := make(map[domain.SessionID]attachmentSession, len(d.sessions))186+ return maps.Clone(d.sessions)
186- for id, entry := range d.sessions {
187- clone[id] = entry
188- }
189- return clone
190 }187 }
191 188
192 func proxyFactoryFor(t *testing.T, key domain.RemoteSessionKey, transports ...ports.Transport) ports.RemoteDialerFactory {189 func proxyFactoryFor(t *testing.T, key domain.RemoteSessionKey, transports ...ports.Transport) ports.RemoteDialerFactory {
@@ -246,7 +243,7 @@ func TestProxyHandshakePublishesOnlyAfterWelcomeAndMetadata(t *testing.T) {
246 require.Equal(t, ports.IntentAttach, hello.Intent)243 require.Equal(t, ports.IntentAttach, hello.Intent)
247 require.True(t, hello.Proxied)244 require.True(t, hello.Proxied)
248 require.Equal(t, key.Name, hello.Name)245 require.Equal(t, key.Name, hello.Name)
249- require.Equal(t, domain.Size{Cols: 80, Rows: 22}, hello.Size)246+ require.Equal(t, contentSize(domain.Size{Cols: 80, Rows: 24}, false), hello.Size)
250 require.Equal(t, uint8(maxUnackedOutputStates), hello.MaxOutputInFlight)247 require.Equal(t, uint8(maxUnackedOutputStates), hello.MaxOutputInFlight)
251 require.Empty(t, proxySessionSnapshot(d), "Hello alone must not publish a proxy")248 require.Empty(t, proxySessionSnapshot(d), "Hello alone must not publish a proxy")
252 249
@@ -457,11 +454,9 @@ func TestProxySenderSerializesAllTransportWrites(t *testing.T) {
457 var wg sync.WaitGroup454 var wg sync.WaitGroup
458 errs := make(chan error, 8)455 errs := make(chan error, 8)
459 for range 8 {456 for range 8 {
460- wg.Add(1)457+ wg.Go(func() {
461- go func() {
462- defer wg.Done()
463 errs <- proxy.sendCurrent(ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})})458 errs <- proxy.sendCurrent(ports.Frame{Type: ports.MsgPing, Payload: ports.MarshalPing(ports.Ping{})})
464- }()459+ })
465 }460 }
466 // Once the first sender enters Send, the others must remain behind sendMu.461 // Once the first sender enters Send, the others must remain behind sendMu.
467 awaitTestCompletion(t, entered, "proxy sender did not enter transport")462 awaitTestCompletion(t, entered, "proxy sender did not enter transport")
@@ -669,6 +664,15 @@ func screenSnapshotForProxy(t *testing.T, proxy *proxySession, state uint64) por
669 return snapshot664 return snapshot
670 }665 }
671 666
667+func TestProxyScreenSnapshotReportsChanged(t *testing.T) {
668+ _, proxy, _, generation := newProxyOutputSession(t)
669+ snapshot := screenSnapshotForProxy(t, proxy, 1)
670+ ack, reset, changed := proxy.applyScreenUpdateForGeneration(generation, snapshot)
671+ require.Equal(t, uint64(1), ack)
672+ require.False(t, reset)
673+ require.True(t, changed, "every accepted snapshot invalidates the composed frame")
674+}
675+
672 func TestProxyScreenUpdate(t *testing.T) {676 func TestProxyScreenUpdate(t *testing.T) {
673 t.Run("initial snapshot", func(t *testing.T) {677 t.Run("initial snapshot", func(t *testing.T) {
674 d, proxy, transport, generation := newProxyOutputSession(t)678 d, proxy, transport, generation := newProxyOutputSession(t)
@@ -812,19 +816,20 @@ func TestProxyScreenUpdate(t *testing.T) {
812 }()816 }()
813 awaitTestCompletion(t, entered, "screen ACK sender did not enter transport")817 awaitTestCompletion(t, entered, "screen ACK sender did not enter transport")
814 818
815- unlocked := make(chan struct{})819+ unlocked := make(chan ports.Transport, 1)
816 go func() {820 go func() {
817 proxy.mu.Lock()821 proxy.mu.Lock()
818 currentTransport := proxy.transport822 currentTransport := proxy.transport
819 proxy.mu.Unlock()823 proxy.mu.Unlock()
820- require.Same(t, transport, currentTransport)824+ unlocked <- currentTransport
821- close(unlocked)
822 }()825 }()
826+ var currentTransport ports.Transport
823 select {827 select {
824- case <-unlocked:828+ case currentTransport = <-unlocked:
825 case <-time.After(time.Second):829 case <-time.After(time.Second):
826 t.Fatal("proxy.mu was held while screen send was blocked")830 t.Fatal("proxy.mu was held while screen send was blocked")
827 }831 }
832+ require.Same(t, transport, currentTransport)
828 require.NoError(t, transport.Close())833 require.NoError(t, transport.Close())
829 require.Error(t, awaitTestValue(t, result, "blocked screen send did not return"))834 require.Error(t, awaitTestValue(t, result, "blocked screen send did not return"))
830 })835 })
M internal/usecase/daemon/proxy_screen_state.go +12 -16
@@ -1,6 +1,7 @@
11 package daemon
22
33 import (
4+ "errors"
45 "fmt"
56 "math"
67 "unicode/utf8"
@@ -40,19 +41,20 @@ func newProxyScreenState(size domain.Size) *proxyScreenState {
4041 if !validProxyScreenSize(size) {
4142 return &proxyScreenState{}
4243 }
43- return &proxyScreenState{
44+ state := &proxyScreenState{
4445 frame: renderer.NewFrame(size.Cols, size.Rows),
4546 scratch: renderer.NewFrame(size.Cols, size.Rows),
4647 cursorOut: ports.ScreenCursor{Visible: true},
47- damage: []renderer.Damage{renderer.FullRedraw()},
4848 generation: 1,
4949 }
50+ state.setFullRedraw()
51+ return state
5052 }
5153
5254 // Apply validates the complete semantic update before mutating either frame.
5355 func (s *proxyScreenState) Apply(update ports.ScreenUpdate) error {
5456 if s == nil {
55- return fmt.Errorf("proxy screen state is nil")
57+ return errors.New("proxy screen state is nil")
5658 }
5759 if err := s.validateUpdate(update); err != nil {
5860 return err
@@ -173,7 +175,7 @@ func (s *proxyScreenState) validateStateNumbers(update ports.ScreenUpdate) error
173175 func applyScreenUpdate(frame *renderer.Frame, update ports.ScreenUpdate) {
174176 if update.Scroll != nil {
175177 scroll := update.Scroll
176- frame.ScrollUp(int(scroll.Top), int(scroll.Top+scroll.Height-1), int(scroll.Count))
178+ frame.ScrollUp(int(scroll.Top), int(scroll.Top)+int(scroll.Height)-1, int(scroll.Count))
177179 }
178180 for _, span := range update.Spans {
179181 copy(frame.Row(int(span.Y))[int(span.X):], span.Cells)
@@ -266,7 +268,7 @@ func (s *proxyScreenState) AcknowledgeDamage(generation uint64) bool {
266268 // replayed incrementally; snapshots, uncertain damage, and dimensions use a
267269 // complete clone.
268270 func (s *proxyScreenState) CaptureInto(dst *renderer.Frame) {
269- if s == nil || dst == nil || !validProxyFrame(*s) {
271+ if s == nil || dst == nil || !validProxyFrame(s) {
270272 return
271273 }
272274 if dst.Width != s.frame.Width || dst.Height != s.frame.Height || dst.Validate() != nil {
@@ -326,15 +328,9 @@ func (s *proxyScreenState) ResizePlaceholder(size domain.Size) bool {
326328 }
327329
328330 func copyFrameOverlap(dst *renderer.Frame, src renderer.Frame) {
329- width := src.Width
330- if dst.Width < width {
331- width = dst.Width
332- }
333- height := src.Height
334- if dst.Height < height {
335- height = dst.Height
336- }
337- for y := 0; y < height; y++ {
331+ width := min(src.Width, dst.Width)
332+ height := min(src.Height, dst.Height)
333+ for y := range height {
338334 copy(dst.Row(y)[:width], src.Row(y)[:width])
339335 }
340336 }
@@ -346,8 +342,8 @@ func validProxyScreenSize(size domain.Size) bool {
346342 return size.Rows <= maxProxyScreenCells/size.Cols
347343 }
348344
349-func validProxyFrame(s proxyScreenState) bool {
350- return s.frame.Validate() == nil && s.scratch.Validate() == nil
345+func validProxyFrame(s *proxyScreenState) bool {
346+ return s != nil && s.frame.Validate() == nil && s.scratch.Validate() == nil
351347 }
352348
353349 func validProxyScreenStyle(style renderer.Style) bool {
@@ -1,6 +1,7 @@
1 package daemon1 package daemon
2 2
3 import (3 import (
4+ "errors"
4 "fmt"5 "fmt"
5 "math"6 "math"
6 "unicode/utf8"7 "unicode/utf8"
@@ -40,19 +41,20 @@ func newProxyScreenState(size domain.Size) *proxyScreenState {
40 if !validProxyScreenSize(size) {41 if !validProxyScreenSize(size) {
41 return &proxyScreenState{}42 return &proxyScreenState{}
42 }43 }
43- return &proxyScreenState{44+ state := &proxyScreenState{
44 frame: renderer.NewFrame(size.Cols, size.Rows),45 frame: renderer.NewFrame(size.Cols, size.Rows),
45 scratch: renderer.NewFrame(size.Cols, size.Rows),46 scratch: renderer.NewFrame(size.Cols, size.Rows),
46 cursorOut: ports.ScreenCursor{Visible: true},47 cursorOut: ports.ScreenCursor{Visible: true},
47- damage: []renderer.Damage{renderer.FullRedraw()},
48 generation: 1,48 generation: 1,
49 }49 }
50+ state.setFullRedraw()
51+ return state
50 }52 }
51 53
52 // Apply validates the complete semantic update before mutating either frame.54 // Apply validates the complete semantic update before mutating either frame.
53 func (s *proxyScreenState) Apply(update ports.ScreenUpdate) error {55 func (s *proxyScreenState) Apply(update ports.ScreenUpdate) error {
54 if s == nil {56 if s == nil {
55- return fmt.Errorf("proxy screen state is nil")57+ return errors.New("proxy screen state is nil")
56 }58 }
57 if err := s.validateUpdate(update); err != nil {59 if err := s.validateUpdate(update); err != nil {
58 return err60 return err
@@ -173,7 +175,7 @@ func (s *proxyScreenState) validateStateNumbers(update ports.ScreenUpdate) error
173 func applyScreenUpdate(frame *renderer.Frame, update ports.ScreenUpdate) {175 func applyScreenUpdate(frame *renderer.Frame, update ports.ScreenUpdate) {
174 if update.Scroll != nil {176 if update.Scroll != nil {
175 scroll := update.Scroll177 scroll := update.Scroll
176- frame.ScrollUp(int(scroll.Top), int(scroll.Top+scroll.Height-1), int(scroll.Count))178+ frame.ScrollUp(int(scroll.Top), int(scroll.Top)+int(scroll.Height)-1, int(scroll.Count))
177 }179 }
178 for _, span := range update.Spans {180 for _, span := range update.Spans {
179 copy(frame.Row(int(span.Y))[int(span.X):], span.Cells)181 copy(frame.Row(int(span.Y))[int(span.X):], span.Cells)
@@ -266,7 +268,7 @@ func (s *proxyScreenState) AcknowledgeDamage(generation uint64) bool {
266 // replayed incrementally; snapshots, uncertain damage, and dimensions use a268 // replayed incrementally; snapshots, uncertain damage, and dimensions use a
267 // complete clone.269 // complete clone.
268 func (s *proxyScreenState) CaptureInto(dst *renderer.Frame) {270 func (s *proxyScreenState) CaptureInto(dst *renderer.Frame) {
269- if s == nil || dst == nil || !validProxyFrame(*s) {271+ if s == nil || dst == nil || !validProxyFrame(s) {
270 return272 return
271 }273 }
272 if dst.Width != s.frame.Width || dst.Height != s.frame.Height || dst.Validate() != nil {274 if dst.Width != s.frame.Width || dst.Height != s.frame.Height || dst.Validate() != nil {
@@ -326,15 +328,9 @@ func (s *proxyScreenState) ResizePlaceholder(size domain.Size) bool {
326 }328 }
327 329
328 func copyFrameOverlap(dst *renderer.Frame, src renderer.Frame) {330 func copyFrameOverlap(dst *renderer.Frame, src renderer.Frame) {
329- width := src.Width331+ width := min(src.Width, dst.Width)
330- if dst.Width < width {332+ height := min(src.Height, dst.Height)
331- width = dst.Width333+ for y := range height {
332- }
333- height := src.Height
334- if dst.Height < height {
335- height = dst.Height
336- }
337- for y := 0; y < height; y++ {
338 copy(dst.Row(y)[:width], src.Row(y)[:width])334 copy(dst.Row(y)[:width], src.Row(y)[:width])
339 }335 }
340 }336 }
@@ -346,8 +342,8 @@ func validProxyScreenSize(size domain.Size) bool {
346 return size.Rows <= maxProxyScreenCells/size.Cols342 return size.Rows <= maxProxyScreenCells/size.Cols
347 }343 }
348 344
349-func validProxyFrame(s proxyScreenState) bool {345+func validProxyFrame(s *proxyScreenState) bool {
350- return s.frame.Validate() == nil && s.scratch.Validate() == nil346+ return s != nil && s.frame.Validate() == nil && s.scratch.Validate() == nil
351 }347 }
352 348
353 func validProxyScreenStyle(style renderer.Style) bool {349 func validProxyScreenStyle(style renderer.Style) bool {
M internal/usecase/daemon/proxy_screen_state_test.go +11 -6
@@ -80,16 +80,21 @@ func TestProxyScreenStateInvalidUpdateIsAtomic(t *testing.T) {
8080 }
8181
8282 func TestProxyScreenStateScreenAreaLimitIsAtomic(t *testing.T) {
83- boundary := newProxyScreenState(domain.Size{Cols: 512, Rows: 512})
84- if boundary.frame.Width != 512 || boundary.frame.Height != 512 || boundary.scratch.Width != 512 || boundary.scratch.Height != 512 {
83+ const boundaryCols = 512
84+ boundaryRows := maxProxyScreenCells / boundaryCols
85+ boundary := newProxyScreenState(domain.Size{Cols: boundaryCols, Rows: boundaryRows})
86+ if boundary.frame.Width != boundaryCols || boundary.frame.Height != boundaryRows || boundary.scratch.Width != boundaryCols || boundary.scratch.Height != boundaryRows {
8587 t.Fatalf("boundary state dimensions = %dx%d and %dx%d", boundary.frame.Width, boundary.frame.Height, boundary.scratch.Width, boundary.scratch.Height)
8688 }
87- if boundary.ResizePlaceholder(domain.Size{Cols: 513, Rows: 512}) {
89+ if boundary.ResizePlaceholder(domain.Size{Cols: boundaryCols, Rows: boundaryRows + 1}) {
8890 t.Fatal("over-cap resize was accepted")
8991 }
90- if boundary.frame.Width != 512 || boundary.frame.Height != 512 || boundary.scratch.Width != 512 || boundary.scratch.Height != 512 {
92+ if boundary.frame.Width != boundaryCols || boundary.frame.Height != boundaryRows || boundary.scratch.Width != boundaryCols || boundary.scratch.Height != boundaryRows {
9193 t.Fatal("over-cap resize changed boundary state")
9294 }
95+ if !boundary.damageFullRedrawSticky {
96+ t.Fatal("initial full redraw must remain sticky until acknowledged")
97+ }
9398
9499 s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
95100 if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil {
@@ -337,7 +342,7 @@ func TestProxyScreenStateCursorOnlyAndDamageBound(t *testing.T) {
337342 if s.cursorOut.Row != 1 || s.cursorOut.Col != 1 || len(s.damage) != 0 {
338343 t.Fatalf("cursor-only update = cursor=%+v damage=%+v", s.cursorOut, s.damage)
339344 }
340- for i := 0; i < maxProxyScreenDamage; i++ {
345+ for range maxProxyScreenDamage {
341346 applyProxyDelta(t, s, ports.ScreenUpdate{
342347 Size: domain.Size{Cols: 2, Rows: 2},
343348 Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("x")}},
@@ -384,7 +389,7 @@ func BenchmarkProxyScreenStateApply(b *testing.B) {
384389 b.ResetTimer()
385390 b.ReportMetric(float64(tc.cells), "cells/op")
386391 b.ReportMetric(float64(tc.spans), "spans/op")
387- for range b.N {
392+ for b.Loop() {
388393 if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil {
389394 b.Fatal(err)
390395 }
@@ -80,16 +80,21 @@ func TestProxyScreenStateInvalidUpdateIsAtomic(t *testing.T) {
80 }80 }
81 81
82 func TestProxyScreenStateScreenAreaLimitIsAtomic(t *testing.T) {82 func TestProxyScreenStateScreenAreaLimitIsAtomic(t *testing.T) {
83- boundary := newProxyScreenState(domain.Size{Cols: 512, Rows: 512})83+ const boundaryCols = 512
84- if boundary.frame.Width != 512 || boundary.frame.Height != 512 || boundary.scratch.Width != 512 || boundary.scratch.Height != 512 {84+ boundaryRows := maxProxyScreenCells / boundaryCols
85+ boundary := newProxyScreenState(domain.Size{Cols: boundaryCols, Rows: boundaryRows})
86+ if boundary.frame.Width != boundaryCols || boundary.frame.Height != boundaryRows || boundary.scratch.Width != boundaryCols || boundary.scratch.Height != boundaryRows {
85 t.Fatalf("boundary state dimensions = %dx%d and %dx%d", boundary.frame.Width, boundary.frame.Height, boundary.scratch.Width, boundary.scratch.Height)87 t.Fatalf("boundary state dimensions = %dx%d and %dx%d", boundary.frame.Width, boundary.frame.Height, boundary.scratch.Width, boundary.scratch.Height)
86 }88 }
87- if boundary.ResizePlaceholder(domain.Size{Cols: 513, Rows: 512}) {89+ if boundary.ResizePlaceholder(domain.Size{Cols: boundaryCols, Rows: boundaryRows + 1}) {
88 t.Fatal("over-cap resize was accepted")90 t.Fatal("over-cap resize was accepted")
89 }91 }
90- if boundary.frame.Width != 512 || boundary.frame.Height != 512 || boundary.scratch.Width != 512 || boundary.scratch.Height != 512 {92+ if boundary.frame.Width != boundaryCols || boundary.frame.Height != boundaryRows || boundary.scratch.Width != boundaryCols || boundary.scratch.Height != boundaryRows {
91 t.Fatal("over-cap resize changed boundary state")93 t.Fatal("over-cap resize changed boundary state")
92 }94 }
95+ if !boundary.damageFullRedrawSticky {
96+ t.Fatal("initial full redraw must remain sticky until acknowledged")
97+ }
93 98
94 s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})99 s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
95 if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil {100 if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil {
@@ -337,7 +342,7 @@ func TestProxyScreenStateCursorOnlyAndDamageBound(t *testing.T) {
337 if s.cursorOut.Row != 1 || s.cursorOut.Col != 1 || len(s.damage) != 0 {342 if s.cursorOut.Row != 1 || s.cursorOut.Col != 1 || len(s.damage) != 0 {
338 t.Fatalf("cursor-only update = cursor=%+v damage=%+v", s.cursorOut, s.damage)343 t.Fatalf("cursor-only update = cursor=%+v damage=%+v", s.cursorOut, s.damage)
339 }344 }
340- for i := 0; i < maxProxyScreenDamage; i++ {345+ for range maxProxyScreenDamage {
341 applyProxyDelta(t, s, ports.ScreenUpdate{346 applyProxyDelta(t, s, ports.ScreenUpdate{
342 Size: domain.Size{Cols: 2, Rows: 2},347 Size: domain.Size{Cols: 2, Rows: 2},
343 Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("x")}},348 Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("x")}},
@@ -384,7 +389,7 @@ func BenchmarkProxyScreenStateApply(b *testing.B) {
384 b.ResetTimer()389 b.ResetTimer()
385 b.ReportMetric(float64(tc.cells), "cells/op")390 b.ReportMetric(float64(tc.cells), "cells/op")
386 b.ReportMetric(float64(tc.spans), "spans/op")391 b.ReportMetric(float64(tc.spans), "spans/op")
387- for range b.N {392+ for b.Loop() {
388 if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil {393 if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil {
389 b.Fatal(err)394 b.Fatal(err)
390 }395 }
M internal/usecase/daemon/proxy_session.go +2 -0
@@ -27,6 +27,8 @@ type proxySession struct {
2727 transport ports.Transport
2828 resumeToken uint64
2929 appliedState uint64
30+ // screenReady is true only after a moving snapshot establishes the
31+ // dependency required by subsequent deltas.
3032 screenReady bool
3133 resetRequested bool
3234 screen *proxyScreenState
@@ -27,6 +27,8 @@ type proxySession struct {
27 transport ports.Transport27 transport ports.Transport
28 resumeToken uint6428 resumeToken uint64
29 appliedState uint6429 appliedState uint64
30+ // screenReady is true only after a moving snapshot establishes the
31+ // dependency required by subsequent deltas.
30 screenReady bool32 screenReady bool
31 resetRequested bool33 resetRequested bool
32 screen *proxyScreenState34 screen *proxyScreenState
M internal/usecase/daemon/render_pipeline_test.go +2 -1
@@ -333,7 +333,8 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
333333 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
334334 client.Write(terminalBytes)
335335 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
336- prepared.commitNoSend()
336+ prepared.draw.Commit()
337+ stream.forceSnapshot = false
337338 again, err := stream.renderer.Draw(composed.frame, nil)
338339 require.NoError(t, err)
339340 require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
@@ -333,7 +333,8 @@ func TestComposeEmitExactReplayTiledFloatingBarsOverlayAndCursor(t *testing.T) {
333 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)333 client := vt.NewScreen(composed.frame.Width, composed.frame.Height)
334 client.Write(terminalBytes)334 client.Write(terminalBytes)
335 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))335 require.Equal(t, frameRows(composed.frame), frameRows(client.Frame))
336- prepared.commitNoSend()336+ prepared.draw.Commit()
337+ stream.forceSnapshot = false
337 again, err := stream.renderer.Draw(composed.frame, nil)338 again, err := stream.renderer.Draw(composed.frame, nil)
338 require.NoError(t, err)339 require.NoError(t, err)
339 require.Empty(t, again, "renderer shadow must exactly equal the composed frame")340 require.Empty(t, again, "renderer shadow must exactly equal the composed frame")
M internal/usecase/daemon/resume_test.go +2 -2
@@ -1399,7 +1399,7 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
13991399 reset := false
14001400 require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint")
14011401
1402- f := ac.output.frame([]byte("incremental while reliable backlog drains"), reset, 0)
1402+ f := outputStateFrame(ac.output, []byte("incremental while reliable backlog drains"), reset, 0)
14031403 ac.sendMu.Unlock()
14041404 out, err := ports.UnmarshalOutput(f.Payload)
14051405 require.NoError(t, err)
@@ -1409,7 +1409,7 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
14091409 ac.sendMu.Lock()
14101410 reset = true
14111411 require.True(t, reset, "explicit reset should still force full repaint")
1412- full := ac.output.frame([]byte("explicit full repaint"), reset, 0)
1412+ full := outputStateFrame(ac.output, []byte("explicit full repaint"), reset, 0)
14131413 ac.sendMu.Unlock()
14141414 fullOut, err := ports.UnmarshalOutput(full.Payload)
14151415 require.NoError(t, err)
@@ -1399,7 +1399,7 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
1399 reset := false1399 reset := false
1400 require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint")1400 require.False(t, reset, "reliable output ack lag alone must not force dependency-free full repaint")
1401 1401
1402- f := ac.output.frame([]byte("incremental while reliable backlog drains"), reset, 0)1402+ f := outputStateFrame(ac.output, []byte("incremental while reliable backlog drains"), reset, 0)
1403 ac.sendMu.Unlock()1403 ac.sendMu.Unlock()
1404 out, err := ports.UnmarshalOutput(f.Payload)1404 out, err := ports.UnmarshalOutput(f.Payload)
1405 require.NoError(t, err)1405 require.NoError(t, err)
@@ -1409,7 +1409,7 @@ func TestOutputAckLagAloneDoesNotForceFullStateRepaint(t *testing.T) {
1409 ac.sendMu.Lock()1409 ac.sendMu.Lock()
1410 reset = true1410 reset = true
1411 require.True(t, reset, "explicit reset should still force full repaint")1411 require.True(t, reset, "explicit reset should still force full repaint")
1412- full := ac.output.frame([]byte("explicit full repaint"), reset, 0)1412+ full := outputStateFrame(ac.output, []byte("explicit full repaint"), reset, 0)
1413 ac.sendMu.Unlock()1413 ac.sendMu.Unlock()
1414 fullOut, err := ports.UnmarshalOutput(full.Payload)1414 fullOut, err := ports.UnmarshalOutput(full.Payload)
1415 require.NoError(t, err)1415 require.NoError(t, err)
M internal/usecase/daemon/screen_output_stream.go +12 -10
@@ -35,6 +35,10 @@ func (s *structuredOutputStream) prepare(frame renderer.Frame, damage []renderer
3535 if err := frame.Validate(); err != nil {
3636 return nil, err
3737 }
38+ size := frameSize(frame)
39+ if !validProxyScreenSize(size) {
40+ return nil, errors.New("structured output: frame geometry is outside wire limits")
41+ }
3842
3943 reset = reset || s.forceSnapshot || s.state.next == 0 || s.shadow.Validate() != nil ||
4044 s.shadow.Width != frame.Width || s.shadow.Height != frame.Height
@@ -91,7 +95,7 @@ func (s *structuredOutputStream) prepare(frame renderer.Frame, damage []renderer
9195 }
9296 update.Spans = make([]ports.ScreenSpan, 0, len(plan.Spans))
9397 for _, span := range plan.Spans {
94- if span.Y < 0 || span.X < 0 || span.Width <= 0 || span.Y >= frame.Height || span.X+span.Width > frame.Width {
98+ if span.Y < 0 || span.X < 0 || span.Width <= 0 || span.Y >= frame.Height || span.X > frame.Width-span.Width {
9599 return nil, errors.New("structured output: invalid planned span")
96100 }
97101 update.Spans = append(update.Spans, ports.ScreenSpan{
@@ -156,43 +160,41 @@ type preparedStructuredOutput struct {
156160 echoAck uint64
157161 data []byte
158162 snapshot bool
159- completed bool
163+ attempted bool
160164 committed bool
161165 }
162166
163167 func (p *preparedStructuredOutput) send(sender func(ports.Frame) error) error {
164- if p == nil || p.completed {
168+ if p == nil || p.attempted {
165169 return nil
166170 }
167171 if len(p.data) == 0 {
168- p.completed = true
172+ p.attempted = true
169173 p.commit()
170174 return nil
171175 }
176+ p.attempted = true
172177 if sender == nil {
173- p.completed = true
174178 p.stream.forceSnapshot = true
175179 return errors.New("structured output: nil sender")
176180 }
177181 if err := sender(ports.Frame{Type: ports.MsgScreenUpdate, Payload: p.data}); err != nil {
178- p.completed = true
179182 p.stream.forceSnapshot = true
180183 return err
181184 }
182- p.completed = true
183185 p.commit()
184186 p.stream.state.next = p.next
185187 return nil
186188 }
187189
188190 // commitNoSend commits a prepared noop without adding a state-bearing frame.
189-// A failed send is already completed, so it cannot accidentally commit after
191+// A failed send is already attempted, so it cannot accidentally commit after
190192 // the failure and thereby destroy the required snapshot retry.
191193 func (p *preparedStructuredOutput) commitNoSend() {
192- if p == nil || p.completed {
194+ if p == nil || p.attempted || len(p.data) != 0 {
193195 return
194196 }
195- p.completed = true
197+ p.attempted = true
196198 p.commit()
197199 }
198200
@@ -35,6 +35,10 @@ func (s *structuredOutputStream) prepare(frame renderer.Frame, damage []renderer
35 if err := frame.Validate(); err != nil {35 if err := frame.Validate(); err != nil {
36 return nil, err36 return nil, err
37 }37 }
38+ size := frameSize(frame)
39+ if !validProxyScreenSize(size) {
40+ return nil, errors.New("structured output: frame geometry is outside wire limits")
41+ }
38 42
39 reset = reset || s.forceSnapshot || s.state.next == 0 || s.shadow.Validate() != nil ||43 reset = reset || s.forceSnapshot || s.state.next == 0 || s.shadow.Validate() != nil ||
40 s.shadow.Width != frame.Width || s.shadow.Height != frame.Height44 s.shadow.Width != frame.Width || s.shadow.Height != frame.Height
@@ -91,7 +95,7 @@ func (s *structuredOutputStream) prepare(frame renderer.Frame, damage []renderer
91 }95 }
92 update.Spans = make([]ports.ScreenSpan, 0, len(plan.Spans))96 update.Spans = make([]ports.ScreenSpan, 0, len(plan.Spans))
93 for _, span := range plan.Spans {97 for _, span := range plan.Spans {
94- if span.Y < 0 || span.X < 0 || span.Width <= 0 || span.Y >= frame.Height || span.X+span.Width > frame.Width {98+ if span.Y < 0 || span.X < 0 || span.Width <= 0 || span.Y >= frame.Height || span.X > frame.Width-span.Width {
95 return nil, errors.New("structured output: invalid planned span")99 return nil, errors.New("structured output: invalid planned span")
96 }100 }
97 update.Spans = append(update.Spans, ports.ScreenSpan{101 update.Spans = append(update.Spans, ports.ScreenSpan{
@@ -156,43 +160,41 @@ type preparedStructuredOutput struct {
156 echoAck uint64160 echoAck uint64
157 data []byte161 data []byte
158 snapshot bool162 snapshot bool
159- completed bool163+ attempted bool
160 committed bool164 committed bool
161 }165 }
162 166
163 func (p *preparedStructuredOutput) send(sender func(ports.Frame) error) error {167 func (p *preparedStructuredOutput) send(sender func(ports.Frame) error) error {
164- if p == nil || p.completed {168+ if p == nil || p.attempted {
165 return nil169 return nil
166 }170 }
167 if len(p.data) == 0 {171 if len(p.data) == 0 {
168- p.completed = true172+ p.attempted = true
169 p.commit()173 p.commit()
170 return nil174 return nil
171 }175 }
176+ p.attempted = true
172 if sender == nil {177 if sender == nil {
173- p.completed = true
174 p.stream.forceSnapshot = true178 p.stream.forceSnapshot = true
175 return errors.New("structured output: nil sender")179 return errors.New("structured output: nil sender")
176 }180 }
177 if err := sender(ports.Frame{Type: ports.MsgScreenUpdate, Payload: p.data}); err != nil {181 if err := sender(ports.Frame{Type: ports.MsgScreenUpdate, Payload: p.data}); err != nil {
178- p.completed = true
179 p.stream.forceSnapshot = true182 p.stream.forceSnapshot = true
180 return err183 return err
181 }184 }
182- p.completed = true
183 p.commit()185 p.commit()
184 p.stream.state.next = p.next186 p.stream.state.next = p.next
185 return nil187 return nil
186 }188 }
187 189
188 // commitNoSend commits a prepared noop without adding a state-bearing frame.190 // commitNoSend commits a prepared noop without adding a state-bearing frame.
189-// A failed send is already completed, so it cannot accidentally commit after191+// A failed send is already attempted, so it cannot accidentally commit after
190 // the failure and thereby destroy the required snapshot retry.192 // the failure and thereby destroy the required snapshot retry.
191 func (p *preparedStructuredOutput) commitNoSend() {193 func (p *preparedStructuredOutput) commitNoSend() {
192- if p == nil || p.completed {194+ if p == nil || p.attempted || len(p.data) != 0 {
193 return195 return
194 }196 }
195- p.completed = true197+ p.attempted = true
196 p.commit()198 p.commit()
197 }199 }
198 200
M internal/usecase/daemon/screen_output_stream_test.go +30 -3
@@ -2,6 +2,7 @@ package daemon
22
33 import (
44 "errors"
5+ "math"
56 "testing"
67
78 "github.com/bnema/vev/internal/ports"
@@ -26,6 +27,10 @@ func TestStructuredOutputStreamSnapshotsThenDeltas(t *testing.T) {
2627 require.Equal(t, uint16(y), span.Y)
2728 require.Zero(t, span.X)
2829 require.Len(t, span.Cells, frame.Width)
30+ for x, cell := range span.Cells {
31+ require.Equal(t, frame.At(x, y).Rune, cell.Rune)
32+ require.Equal(t, frame.At(x, y).Continuation, cell.Continuation)
33+ }
2934 }
3035 require.NoError(t, first.send(func(got ports.Frame) error {
3136 require.Equal(t, ports.MsgScreenUpdate, got.Type)
@@ -210,6 +215,27 @@ func TestStructuredOutputStreamComparesCursorValidityAndHidden(t *testing.T) {
210215 require.Empty(t, noop.data)
211216 }
212217
218+func TestStructuredOutputStreamRejectsInvalidCursorAndGeometry(t *testing.T) {
219+ cases := []struct {
220+ name string
221+ frame renderer.Frame
222+ cursor cursorOut
223+ }{
224+ {name: "cursor row outside frame", frame: structuredTestFrame("a"), cursor: cursorOut{valid: true, row: 1}},
225+ {name: "cursor col outside frame", frame: structuredTestFrame("a"), cursor: cursorOut{valid: true, col: 1}},
226+ {name: "screen cell cap", frame: renderer.NewFrame(513, 512), cursor: cursorOut{valid: true}},
227+ {name: "uint16 dimension cap", frame: renderer.NewFrame(math.MaxUint16+1, 1), cursor: cursorOut{valid: true}},
228+ }
229+ for _, tc := range cases {
230+ t.Run(tc.name, func(t *testing.T) {
231+ stream := newStructuredOutputStream(newOutputStateStream())
232+ _, err := stream.prepare(tc.frame, nil, tc.cursor, true, 0)
233+ require.Error(t, err)
234+ require.Zero(t, stream.state.next, "rejected geometry must not advance state")
235+ })
236+ }
237+}
238+
213239 func TestStructuredOutputStreamPreservesExplicitZeroCursorStyle(t *testing.T) {
214240 state := newOutputStateStream()
215241 stream := newStructuredOutputStream(state)
@@ -308,10 +334,11 @@ func BenchmarkStructuredOutputPrepare(b *testing.B) {
308334 if err := initial.send(func(ports.Frame) error { return nil }); err != nil {
309335 b.Fatal(err)
310336 }
311- var wireBytes, spans, snapshots int
337+ var wireBytes, spans, snapshots, mutation int
312338 b.ResetTimer()
313- for i := 0; i < b.N; i++ {
314- fixture.mutate(fixture.frame, i)
339+ for b.Loop() {
340+ fixture.mutate(fixture.frame, mutation)
341+ mutation++
315342 prepared, err := stream.prepare(fixture.frame, fixture.damage, cursor, false, 0)
316343 if err != nil {
317344 b.Fatal(err)
@@ -2,6 +2,7 @@ package daemon
2 2
3 import (3 import (
4 "errors"4 "errors"
5+ "math"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/vev/internal/ports"8 "github.com/bnema/vev/internal/ports"
@@ -26,6 +27,10 @@ func TestStructuredOutputStreamSnapshotsThenDeltas(t *testing.T) {
26 require.Equal(t, uint16(y), span.Y)27 require.Equal(t, uint16(y), span.Y)
27 require.Zero(t, span.X)28 require.Zero(t, span.X)
28 require.Len(t, span.Cells, frame.Width)29 require.Len(t, span.Cells, frame.Width)
30+ for x, cell := range span.Cells {
31+ require.Equal(t, frame.At(x, y).Rune, cell.Rune)
32+ require.Equal(t, frame.At(x, y).Continuation, cell.Continuation)
33+ }
29 }34 }
30 require.NoError(t, first.send(func(got ports.Frame) error {35 require.NoError(t, first.send(func(got ports.Frame) error {
31 require.Equal(t, ports.MsgScreenUpdate, got.Type)36 require.Equal(t, ports.MsgScreenUpdate, got.Type)
@@ -210,6 +215,27 @@ func TestStructuredOutputStreamComparesCursorValidityAndHidden(t *testing.T) {
210 require.Empty(t, noop.data)215 require.Empty(t, noop.data)
211 }216 }
212 217
218+func TestStructuredOutputStreamRejectsInvalidCursorAndGeometry(t *testing.T) {
219+ cases := []struct {
220+ name string
221+ frame renderer.Frame
222+ cursor cursorOut
223+ }{
224+ {name: "cursor row outside frame", frame: structuredTestFrame("a"), cursor: cursorOut{valid: true, row: 1}},
225+ {name: "cursor col outside frame", frame: structuredTestFrame("a"), cursor: cursorOut{valid: true, col: 1}},
226+ {name: "screen cell cap", frame: renderer.NewFrame(513, 512), cursor: cursorOut{valid: true}},
227+ {name: "uint16 dimension cap", frame: renderer.NewFrame(math.MaxUint16+1, 1), cursor: cursorOut{valid: true}},
228+ }
229+ for _, tc := range cases {
230+ t.Run(tc.name, func(t *testing.T) {
231+ stream := newStructuredOutputStream(newOutputStateStream())
232+ _, err := stream.prepare(tc.frame, nil, tc.cursor, true, 0)
233+ require.Error(t, err)
234+ require.Zero(t, stream.state.next, "rejected geometry must not advance state")
235+ })
236+ }
237+}
238+
213 func TestStructuredOutputStreamPreservesExplicitZeroCursorStyle(t *testing.T) {239 func TestStructuredOutputStreamPreservesExplicitZeroCursorStyle(t *testing.T) {
214 state := newOutputStateStream()240 state := newOutputStateStream()
215 stream := newStructuredOutputStream(state)241 stream := newStructuredOutputStream(state)
@@ -308,10 +334,11 @@ func BenchmarkStructuredOutputPrepare(b *testing.B) {
308 if err := initial.send(func(ports.Frame) error { return nil }); err != nil {334 if err := initial.send(func(ports.Frame) error { return nil }); err != nil {
309 b.Fatal(err)335 b.Fatal(err)
310 }336 }
311- var wireBytes, spans, snapshots int337+ var wireBytes, spans, snapshots, mutation int
312 b.ResetTimer()338 b.ResetTimer()
313- for i := 0; i < b.N; i++ {339+ for b.Loop() {
314- fixture.mutate(fixture.frame, i)340+ fixture.mutate(fixture.frame, mutation)
341+ mutation++
315 prepared, err := stream.prepare(fixture.frame, fixture.damage, cursor, false, 0)342 prepared, err := stream.prepare(fixture.frame, fixture.damage, cursor, false, 0)
316 if err != nil {343 if err != nil {
317 b.Fatal(err)344 b.Fatal(err)
M internal/usecase/daemon/session_meta_test.go +6 -1
@@ -4,6 +4,7 @@ import (
44 "errors"
55 "io"
66 "testing"
7+ "time"
78
89 "github.com/bnema/vev/internal/ports"
910 "github.com/stretchr/testify/require"
@@ -83,7 +84,11 @@ func TestProxiedMetadataSendsWithoutOutputBytes(t *testing.T) {
8384 meta, err := ports.UnmarshalSessionMeta(metaFrame.Payload)
8485 require.NoError(t, err)
8586 require.Equal(t, "renamed", meta.Tabs[0].Name)
86- requireNoOutputFrame(t, sends)
87+ select {
88+ case frame := <-sends:
89+ t.Fatalf("unexpected frame after metadata-only paint: %v", frame.Type)
90+ case <-time.After(50 * time.Millisecond):
91+ }
8792 }
8893
8994 func TestSessionMetaSnapshotIsImmutable(t *testing.T) {
@@ -4,6 +4,7 @@ import (
4 "errors"4 "errors"
5 "io"5 "io"
6 "testing"6 "testing"
7+ "time"
7 8
8 "github.com/bnema/vev/internal/ports"9 "github.com/bnema/vev/internal/ports"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
@@ -83,7 +84,11 @@ func TestProxiedMetadataSendsWithoutOutputBytes(t *testing.T) {
83 meta, err := ports.UnmarshalSessionMeta(metaFrame.Payload)84 meta, err := ports.UnmarshalSessionMeta(metaFrame.Payload)
84 require.NoError(t, err)85 require.NoError(t, err)
85 require.Equal(t, "renamed", meta.Tabs[0].Name)86 require.Equal(t, "renamed", meta.Tabs[0].Name)
86- requireNoOutputFrame(t, sends)87+ select {
88+ case frame := <-sends:
89+ t.Fatalf("unexpected frame after metadata-only paint: %v", frame.Type)
90+ case <-time.After(50 * time.Millisecond):
91+ }
87 }92 }
88 93
89 func TestSessionMetaSnapshotIsImmutable(t *testing.T) {94 func TestSessionMetaSnapshotIsImmutable(t *testing.T) {
M internal/usecase/daemon/snatched_render_test.go +2 -0
@@ -142,6 +142,7 @@ func TestSendSnatchedPanelUsesStructuredSnapshotForProxied(t *testing.T) {
142142 d := newTestDaemon(t, nil, stubClock{})
143143 tr := &closeTrackingTransport{}
144144 ac := &attachedClient{tr: tr, output: newOutputStateStream(), proxied: true, size: domain.Size{Cols: 80, Rows: 24}}
145+ ac.output.next = 7
145146 ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
146147 ac.roleGeneration.Store(1)
147148
@@ -153,6 +154,7 @@ func TestSendSnatchedPanelUsesStructuredSnapshotForProxied(t *testing.T) {
153154 require.NoError(t, err)
154155 require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind)
155156 require.Zero(t, update.BaseStateNum)
157+ require.Equal(t, uint64(8), update.NewStateNum, "snatched panel must advance the shared output state")
156158 require.False(t, update.Cursor.Visible, "structured snatched panel must hide the cursor semantically")
157159 require.Len(t, update.Spans, ac.size.Rows, "structured snapshots must carry every full row")
158160 }
@@ -142,6 +142,7 @@ func TestSendSnatchedPanelUsesStructuredSnapshotForProxied(t *testing.T) {
142 d := newTestDaemon(t, nil, stubClock{})142 d := newTestDaemon(t, nil, stubClock{})
143 tr := &closeTrackingTransport{}143 tr := &closeTrackingTransport{}
144 ac := &attachedClient{tr: tr, output: newOutputStateStream(), proxied: true, size: domain.Size{Cols: 80, Rows: 24}}144 ac := &attachedClient{tr: tr, output: newOutputStateStream(), proxied: true, size: domain.Size{Cols: 80, Rows: 24}}
145+ ac.output.next = 7
145 ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})146 ac.setAppliedTheme(appliedTheme{Resolved: themeui.Resolve(themeui.Theme{}, domain.ThemeAccent{Mode: domain.ThemeAccentAuto})})
146 ac.roleGeneration.Store(1)147 ac.roleGeneration.Store(1)
147 148
@@ -153,6 +154,7 @@ func TestSendSnatchedPanelUsesStructuredSnapshotForProxied(t *testing.T) {
153 require.NoError(t, err)154 require.NoError(t, err)
154 require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind)155 require.Equal(t, ports.ScreenUpdateSnapshot, update.Kind)
155 require.Zero(t, update.BaseStateNum)156 require.Zero(t, update.BaseStateNum)
157+ require.Equal(t, uint64(8), update.NewStateNum, "snatched panel must advance the shared output state")
156 require.False(t, update.Cursor.Visible, "structured snatched panel must hide the cursor semantically")158 require.False(t, update.Cursor.Visible, "structured snatched panel must hide the cursor semantically")
157 require.Len(t, update.Spans, ac.size.Rows, "structured snapshots must carry every full row")159 require.Len(t, update.Spans, ac.size.Rows, "structured snapshots must carry every full row")
158 }160 }
M internal/usecase/daemon/structured_diagnostics_test.go +9 -10
@@ -40,7 +40,7 @@ func TestP4StructuredRuntimeDiagnosticsRecordAcceptedSnapshotAndDelta(t *testing
4040 }
4141
4242 snapshot := emit(true)
43- marks := structuredScreenMarks(observer)
43+ marks := structuredScreenMarks(t, observer)
4444 require.Len(t, marks, 1)
4545 require.Equal(t, ports.RuntimeScreenSnapshot, marks[0].Kind)
4646 require.Equal(t, uint64(len(mustMarshalScreenUpdate(t, snapshot))), marks[0].Bytes)
@@ -57,7 +57,7 @@ func TestP4StructuredRuntimeDiagnosticsRecordAcceptedSnapshotAndDelta(t *testing
5757 observer.marks = nil
5858
5959 delta := emit(false)
60- marks = structuredScreenMarks(observer)
60+ marks = structuredScreenMarks(t, observer)
6161 require.Len(t, marks, 1)
6262 require.Equal(t, ports.RuntimeScreenDelta, marks[0].Kind)
6363 require.Equal(t, uint64(len(mustMarshalScreenUpdate(t, delta))), marks[0].Bytes)
@@ -79,7 +79,7 @@ func TestP4StructuredRuntimeDiagnosticsDoNotRecordFailedSend(t *testing.T) {
7979 require.True(t, ok)
8080 composed := composeFrame(*state, composeCacheInput{})
8181 require.True(t, d.emitFrame(proxy, ac, state, composed))
82- require.Empty(t, structuredScreenMarks(observer))
82+ require.Empty(t, structuredScreenMarks(t, observer))
8383 }
8484
8585 func TestP4StructuredRuntimeDiagnosticsResetOnlyAfterAcceptedRequest(t *testing.T) {
@@ -91,7 +91,7 @@ func TestP4StructuredRuntimeDiagnosticsResetOnlyAfterAcceptedRequest(t *testing.
9191 Size: domain.Size{Cols: 80, Rows: 22},
9292 }
9393 require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, invalid))
94- marks := structuredScreenMarks(observer)
94+ marks := structuredScreenMarks(t, observer)
9595 require.Len(t, marks, 1)
9696 require.Equal(t, ports.RuntimeScreenResetRequested, marks[0].Kind)
9797 require.Zero(t, marks[0].Bytes)
@@ -100,13 +100,13 @@ func TestP4StructuredRuntimeDiagnosticsResetOnlyAfterAcceptedRequest(t *testing.
100100
101101 // The already-requested reset is not sent or measured a second time.
102102 require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, invalid))
103- require.Len(t, structuredScreenMarks(observer), 1)
103+ require.Len(t, structuredScreenMarks(t, observer), 1)
104104
105105 d, proxy, transport, generation = newProxyOutputSession(t)
106106 d.runtimeObserver = &daemonRuntimeObserver{}
107107 transport.sendFails.Store(true)
108108 require.Error(t, d.handleProxyScreenUpdate(proxy, generation, invalid))
109- require.Empty(t, structuredScreenMarks(d.runtimeObserver.(*daemonRuntimeObserver)))
109+ require.Empty(t, structuredScreenMarks(t, d.runtimeObserver))
110110 }
111111
112112 func TestP4StructuredRuntimeDiagnosticsObserverRunsOutsideAttachmentAndProxyLocks(t *testing.T) {
@@ -155,11 +155,10 @@ func TestP4StructuredRuntimeDiagnosticsObserverRunsOutsideAttachmentAndProxyLock
155155 awaitDaemonObserver(t, done, "structured render")
156156 }
157157
158-func structuredScreenMarks(observer ports.RuntimeObserver) []ports.RuntimeMark {
158+func structuredScreenMarks(t *testing.T, observer ports.RuntimeObserver) []ports.RuntimeMark {
159+ t.Helper()
159160 collector, ok := observer.(*daemonRuntimeObserver)
160- if !ok {
161- return nil
162- }
161+ require.True(t, ok, "runtime observer must be the daemon test collector")
163162 var marks []ports.RuntimeMark
164163 for _, mark := range collector.marks {
165164 switch mark.Kind {
@@ -40,7 +40,7 @@ func TestP4StructuredRuntimeDiagnosticsRecordAcceptedSnapshotAndDelta(t *testing
40 }40 }
41 41
42 snapshot := emit(true)42 snapshot := emit(true)
43- marks := structuredScreenMarks(observer)43+ marks := structuredScreenMarks(t, observer)
44 require.Len(t, marks, 1)44 require.Len(t, marks, 1)
45 require.Equal(t, ports.RuntimeScreenSnapshot, marks[0].Kind)45 require.Equal(t, ports.RuntimeScreenSnapshot, marks[0].Kind)
46 require.Equal(t, uint64(len(mustMarshalScreenUpdate(t, snapshot))), marks[0].Bytes)46 require.Equal(t, uint64(len(mustMarshalScreenUpdate(t, snapshot))), marks[0].Bytes)
@@ -57,7 +57,7 @@ func TestP4StructuredRuntimeDiagnosticsRecordAcceptedSnapshotAndDelta(t *testing
57 observer.marks = nil57 observer.marks = nil
58 58
59 delta := emit(false)59 delta := emit(false)
60- marks = structuredScreenMarks(observer)60+ marks = structuredScreenMarks(t, observer)
61 require.Len(t, marks, 1)61 require.Len(t, marks, 1)
62 require.Equal(t, ports.RuntimeScreenDelta, marks[0].Kind)62 require.Equal(t, ports.RuntimeScreenDelta, marks[0].Kind)
63 require.Equal(t, uint64(len(mustMarshalScreenUpdate(t, delta))), marks[0].Bytes)63 require.Equal(t, uint64(len(mustMarshalScreenUpdate(t, delta))), marks[0].Bytes)
@@ -79,7 +79,7 @@ func TestP4StructuredRuntimeDiagnosticsDoNotRecordFailedSend(t *testing.T) {
79 require.True(t, ok)79 require.True(t, ok)
80 composed := composeFrame(*state, composeCacheInput{})80 composed := composeFrame(*state, composeCacheInput{})
81 require.True(t, d.emitFrame(proxy, ac, state, composed))81 require.True(t, d.emitFrame(proxy, ac, state, composed))
82- require.Empty(t, structuredScreenMarks(observer))82+ require.Empty(t, structuredScreenMarks(t, observer))
83 }83 }
84 84
85 func TestP4StructuredRuntimeDiagnosticsResetOnlyAfterAcceptedRequest(t *testing.T) {85 func TestP4StructuredRuntimeDiagnosticsResetOnlyAfterAcceptedRequest(t *testing.T) {
@@ -91,7 +91,7 @@ func TestP4StructuredRuntimeDiagnosticsResetOnlyAfterAcceptedRequest(t *testing.
91 Size: domain.Size{Cols: 80, Rows: 22},91 Size: domain.Size{Cols: 80, Rows: 22},
92 }92 }
93 require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, invalid))93 require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, invalid))
94- marks := structuredScreenMarks(observer)94+ marks := structuredScreenMarks(t, observer)
95 require.Len(t, marks, 1)95 require.Len(t, marks, 1)
96 require.Equal(t, ports.RuntimeScreenResetRequested, marks[0].Kind)96 require.Equal(t, ports.RuntimeScreenResetRequested, marks[0].Kind)
97 require.Zero(t, marks[0].Bytes)97 require.Zero(t, marks[0].Bytes)
@@ -100,13 +100,13 @@ func TestP4StructuredRuntimeDiagnosticsResetOnlyAfterAcceptedRequest(t *testing.
100 100
101 // The already-requested reset is not sent or measured a second time.101 // The already-requested reset is not sent or measured a second time.
102 require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, invalid))102 require.NoError(t, d.handleProxyScreenUpdate(proxy, generation, invalid))
103- require.Len(t, structuredScreenMarks(observer), 1)103+ require.Len(t, structuredScreenMarks(t, observer), 1)
104 104
105 d, proxy, transport, generation = newProxyOutputSession(t)105 d, proxy, transport, generation = newProxyOutputSession(t)
106 d.runtimeObserver = &daemonRuntimeObserver{}106 d.runtimeObserver = &daemonRuntimeObserver{}
107 transport.sendFails.Store(true)107 transport.sendFails.Store(true)
108 require.Error(t, d.handleProxyScreenUpdate(proxy, generation, invalid))108 require.Error(t, d.handleProxyScreenUpdate(proxy, generation, invalid))
109- require.Empty(t, structuredScreenMarks(d.runtimeObserver.(*daemonRuntimeObserver)))109+ require.Empty(t, structuredScreenMarks(t, d.runtimeObserver))
110 }110 }
111 111
112 func TestP4StructuredRuntimeDiagnosticsObserverRunsOutsideAttachmentAndProxyLocks(t *testing.T) {112 func TestP4StructuredRuntimeDiagnosticsObserverRunsOutsideAttachmentAndProxyLocks(t *testing.T) {
@@ -155,11 +155,10 @@ func TestP4StructuredRuntimeDiagnosticsObserverRunsOutsideAttachmentAndProxyLock
155 awaitDaemonObserver(t, done, "structured render")155 awaitDaemonObserver(t, done, "structured render")
156 }156 }
157 157
158-func structuredScreenMarks(observer ports.RuntimeObserver) []ports.RuntimeMark {158+func structuredScreenMarks(t *testing.T, observer ports.RuntimeObserver) []ports.RuntimeMark {
159+ t.Helper()
159 collector, ok := observer.(*daemonRuntimeObserver)160 collector, ok := observer.(*daemonRuntimeObserver)
160- if !ok {161+ require.True(t, ok, "runtime observer must be the daemon test collector")
161- return nil
162- }
163 var marks []ports.RuntimeMark162 var marks []ports.RuntimeMark
164 for _, mark := range collector.marks {163 for _, mark := range collector.marks {
165 switch mark.Kind {164 switch mark.Kind {

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