detached 087e78d

Merge pull request #167 from bnema/feat/proxy-screen-wire

feat(protocol): add structured proxy screen wire
commit
087e78d9938349d81c117ce8f4ea340cc924b51c
author
bnema · · 2026-08-02 06:10 UTC
committer
GitHub · · 2026-08-02 06:10 UTC
merge of
0a7eb6a + 4fabf6f · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
11 files · +1979 -25
M internal/adapters/ipc/transport.go +3 -8
@@ -10,11 +10,6 @@ import (
1010 "github.com/bnema/vev/internal/ports"
1111 )
1212
13-// maxFrameLen is the largest permitted frame length (the length field
14-// covers the type byte plus payload, not including the 4-byte length
15-// prefix itself).
16-const maxFrameLen = 16 << 20 // 16 MiB
17-
1813 // frameHeaderLen is the size, in bytes, of the length prefix that precedes
1914 // every frame on the wire.
2015 const frameHeaderLen = 4
@@ -24,7 +19,7 @@ const frameHeaderLen = 4
2419 var ErrZeroLengthFrame = errors.New("ipc: zero-length frame")
2520
2621 // ErrFrameTooLarge is returned by Recv when a frame's length field exceeds
27-// maxFrameLen, and by Send when a payload is too large to encode.
22+// ports.MaxFrameLen, and by Send when a payload is too large to encode.
2823 var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length")
2924
3025 // ErrBackpressure means the bounded IPC egress queue is full. Callers using
@@ -144,7 +139,7 @@ func (t *unixTransport) SendAsync(f ports.Frame) error {
144139
145140 func marshalFrame(f ports.Frame) ([]byte, error) {
146141 n := 1 + len(f.Payload) // type + payload
147- if n > maxFrameLen {
142+ if n > ports.MaxFrameLen {
148143 return nil, ErrFrameTooLarge
149144 }
150145 buf := make([]byte, frameHeaderLen+n)
@@ -274,7 +269,7 @@ func (t *unixTransport) Recv() (ports.Frame, error) {
274269 end(false)
275270 return ports.Frame{}, ErrZeroLengthFrame
276271 }
277- if n > maxFrameLen {
272+ if n > ports.MaxFrameLen {
278273 end(false)
279274 return ports.Frame{}, ErrFrameTooLarge
280275 }
@@ -10,11 +10,6 @@ import (
10 "github.com/bnema/vev/internal/ports"10 "github.com/bnema/vev/internal/ports"
11 )11 )
12 12
13-// maxFrameLen is the largest permitted frame length (the length field
14-// covers the type byte plus payload, not including the 4-byte length
15-// prefix itself).
16-const maxFrameLen = 16 << 20 // 16 MiB
17-
18 // frameHeaderLen is the size, in bytes, of the length prefix that precedes13 // frameHeaderLen is the size, in bytes, of the length prefix that precedes
19 // every frame on the wire.14 // every frame on the wire.
20 const frameHeaderLen = 415 const frameHeaderLen = 4
@@ -24,7 +19,7 @@ const frameHeaderLen = 4
24 var ErrZeroLengthFrame = errors.New("ipc: zero-length frame")19 var ErrZeroLengthFrame = errors.New("ipc: zero-length frame")
25 20
26 // ErrFrameTooLarge is returned by Recv when a frame's length field exceeds21 // ErrFrameTooLarge is returned by Recv when a frame's length field exceeds
27-// maxFrameLen, and by Send when a payload is too large to encode.22+// ports.MaxFrameLen, and by Send when a payload is too large to encode.
28 var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length")23 var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length")
29 24
30 // ErrBackpressure means the bounded IPC egress queue is full. Callers using25 // ErrBackpressure means the bounded IPC egress queue is full. Callers using
@@ -144,7 +139,7 @@ func (t *unixTransport) SendAsync(f ports.Frame) error {
144 139
145 func marshalFrame(f ports.Frame) ([]byte, error) {140 func marshalFrame(f ports.Frame) ([]byte, error) {
146 n := 1 + len(f.Payload) // type + payload141 n := 1 + len(f.Payload) // type + payload
147- if n > maxFrameLen {142+ if n > ports.MaxFrameLen {
148 return nil, ErrFrameTooLarge143 return nil, ErrFrameTooLarge
149 }144 }
150 buf := make([]byte, frameHeaderLen+n)145 buf := make([]byte, frameHeaderLen+n)
@@ -274,7 +269,7 @@ func (t *unixTransport) Recv() (ports.Frame, error) {
274 end(false)269 end(false)
275 return ports.Frame{}, ErrZeroLengthFrame270 return ports.Frame{}, ErrZeroLengthFrame
276 }271 }
277- if n > maxFrameLen {272+ if n > ports.MaxFrameLen {
278 end(false)273 end(false)
279 return ports.Frame{}, ErrFrameTooLarge274 return ports.Frame{}, ErrFrameTooLarge
280 }275 }
M internal/adapters/ipc/transport_test.go +4 -4
@@ -96,8 +96,8 @@ func TestTransportLargeFrameNearCap(t *testing.T) {
9696 server := NewTransport(c2)
9797
9898 // Largest payload whose frame length (1 + len(payload)) still fits
99- // exactly at maxFrameLen.
100- payload := make([]byte, maxFrameLen-1)
99+ // exactly at ports.MaxFrameLen.
100+ payload := make([]byte, ports.MaxFrameLen-1)
101101 for i := range payload {
102102 payload[i] = byte(i)
103103 }
@@ -177,7 +177,7 @@ func TestTransportRecvOversizeFrame(t *testing.T) {
177177
178178 go func() {
179179 var hdr [4]byte
180- binary.BigEndian.PutUint32(hdr[:], maxFrameLen+1)
180+ binary.BigEndian.PutUint32(hdr[:], ports.MaxFrameLen+1)
181181 _, _ = c1.Write(hdr[:])
182182 }()
183183
@@ -227,7 +227,7 @@ func TestTransportSendOversizePayloadRejected(t *testing.T) {
227227 defer func() { _ = c2.Close() }()
228228
229229 client := NewTransport(c1)
230- payload := make([]byte, maxFrameLen) // +1 byte type => exceeds max
230+ payload := make([]byte, ports.MaxFrameLen) // +1 byte type => exceeds max
231231 err := client.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload})
232232 if !errors.Is(err, ErrFrameTooLarge) {
233233 t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err)
@@ -96,8 +96,8 @@ func TestTransportLargeFrameNearCap(t *testing.T) {
96 server := NewTransport(c2)96 server := NewTransport(c2)
97 97
98 // Largest payload whose frame length (1 + len(payload)) still fits98 // Largest payload whose frame length (1 + len(payload)) still fits
99- // exactly at maxFrameLen.99+ // exactly at ports.MaxFrameLen.
100- payload := make([]byte, maxFrameLen-1)100+ payload := make([]byte, ports.MaxFrameLen-1)
101 for i := range payload {101 for i := range payload {
102 payload[i] = byte(i)102 payload[i] = byte(i)
103 }103 }
@@ -177,7 +177,7 @@ func TestTransportRecvOversizeFrame(t *testing.T) {
177 177
178 go func() {178 go func() {
179 var hdr [4]byte179 var hdr [4]byte
180- binary.BigEndian.PutUint32(hdr[:], maxFrameLen+1)180+ binary.BigEndian.PutUint32(hdr[:], ports.MaxFrameLen+1)
181 _, _ = c1.Write(hdr[:])181 _, _ = c1.Write(hdr[:])
182 }()182 }()
183 183
@@ -227,7 +227,7 @@ func TestTransportSendOversizePayloadRejected(t *testing.T) {
227 defer func() { _ = c2.Close() }()227 defer func() { _ = c2.Close() }()
228 228
229 client := NewTransport(c1)229 client := NewTransport(c1)
230- payload := make([]byte, maxFrameLen) // +1 byte type => exceeds max230+ payload := make([]byte, ports.MaxFrameLen) // +1 byte type => exceeds max
231 err := client.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload})231 err := client.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload})
232 if !errors.Is(err, ErrFrameTooLarge) {232 if !errors.Is(err, ErrFrameTooLarge) {
233 t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err)233 t.Fatalf("client.Send() error = %v, want ErrFrameTooLarge", err)
M internal/adapters/sshstdio/transport.go +2 -3
@@ -21,7 +21,6 @@ import (
2121 )
2222
2323 const (
24- maxFrameLen = 16 << 20
2524 frameHeaderLen = 4
2625 sshCloseTimeout = 3 * time.Second
2726 )
@@ -78,7 +77,7 @@ type transport struct {
7877 func (t *transport) Send(f ports.Frame) error {
7978 end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
8079 n := 1 + len(f.Payload)
81- if n > maxFrameLen {
80+ if n > ports.MaxFrameLen {
8281 end(false)
8382 return ErrFrameTooLarge
8483 }
@@ -109,7 +108,7 @@ func (t *transport) Recv() (ports.Frame, error) {
109108 end(false)
110109 return ports.Frame{}, ErrZeroLengthFrame
111110 }
112- if n > maxFrameLen {
111+ if n > ports.MaxFrameLen {
113112 end(false)
114113 return ports.Frame{}, ErrFrameTooLarge
115114 }
@@ -21,7 +21,6 @@ import (
21 )21 )
22 22
23 const (23 const (
24- maxFrameLen = 16 << 20
25 frameHeaderLen = 424 frameHeaderLen = 4
26 sshCloseTimeout = 3 * time.Second25 sshCloseTimeout = 3 * time.Second
27 )26 )
@@ -78,7 +77,7 @@ type transport struct {
78 func (t *transport) Send(f ports.Frame) error {77 func (t *transport) Send(f ports.Frame) error {
79 end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))78 end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload)))
80 n := 1 + len(f.Payload)79 n := 1 + len(f.Payload)
81- if n > maxFrameLen {80+ if n > ports.MaxFrameLen {
82 end(false)81 end(false)
83 return ErrFrameTooLarge82 return ErrFrameTooLarge
84 }83 }
@@ -109,7 +108,7 @@ func (t *transport) Recv() (ports.Frame, error) {
109 end(false)108 end(false)
110 return ports.Frame{}, ErrZeroLengthFrame109 return ports.Frame{}, ErrZeroLengthFrame
111 }110 }
112- if n > maxFrameLen {111+ if n > ports.MaxFrameLen {
113 end(false)112 end(false)
114 return ports.Frame{}, ErrFrameTooLarge113 return ports.Frame{}, ErrFrameTooLarge
115 }114 }
M internal/adapters/sshstdio/transport_test.go +35 -0
@@ -3,6 +3,7 @@ package sshstdio
33 import (
44 "bytes"
55 "context"
6+ "encoding/binary"
67 "errors"
78 "io"
89 "log/slog"
@@ -164,6 +165,40 @@ func TestTransportRejectsZeroLengthFrame(t *testing.T) {
164165 }
165166 }
166167
168+func TestTransportRejectsFramesOverSharedMaximum(t *testing.T) {
169+ boundaryPayload := make([]byte, ports.MaxFrameLen-1)
170+ boundaryWire := &bytes.Buffer{}
171+ boundarySend := NewTransport(nil, boundaryWire, nil)
172+ if err := boundarySend.Send(ports.Frame{Type: ports.MsgOutput, Payload: boundaryPayload}); err != nil {
173+ t.Fatalf("boundary Send error = %v", err)
174+ }
175+ if got := binary.BigEndian.Uint32(boundaryWire.Bytes()[:frameHeaderLen]); got != ports.MaxFrameLen {
176+ t.Fatalf("boundary frame length = %d, want %d", got, ports.MaxFrameLen)
177+ }
178+ boundaryRecv := NewTransport(bytes.NewReader(boundaryWire.Bytes()), io.Discard, nil)
179+ boundaryFrame, boundaryErr := boundaryRecv.Recv()
180+ if boundaryErr != nil {
181+ t.Fatalf("boundary Recv error = %v", boundaryErr)
182+ }
183+ if boundaryFrame.Type != ports.MsgOutput || len(boundaryFrame.Payload) != len(boundaryPayload) {
184+ t.Fatalf("boundary frame = type %d, payload %d bytes; want type %d, payload %d bytes", boundaryFrame.Type, len(boundaryFrame.Payload), ports.MsgOutput, len(boundaryPayload))
185+ }
186+
187+ send := NewTransport(nil, io.Discard, nil)
188+ err := send.Send(ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, ports.MaxFrameLen)})
189+ if !errors.Is(err, ErrFrameTooLarge) {
190+ t.Fatalf("Send error = %v, want ErrFrameTooLarge", err)
191+ }
192+
193+ var header [frameHeaderLen]byte
194+ binary.BigEndian.PutUint32(header[:], ports.MaxFrameLen+1)
195+ recv := NewTransport(bytes.NewReader(header[:]), io.Discard, nil)
196+ _, err = recv.Recv()
197+ if !errors.Is(err, ErrFrameTooLarge) {
198+ t.Fatalf("Recv error = %v, want ErrFrameTooLarge", err)
199+ }
200+}
201+
167202 func TestDialContextCanceledBeforeStart(t *testing.T) {
168203 ctx, cancel := context.WithCancel(context.Background())
169204 cancel()
@@ -3,6 +3,7 @@ package sshstdio
3 import (3 import (
4 "bytes"4 "bytes"
5 "context"5 "context"
6+ "encoding/binary"
6 "errors"7 "errors"
7 "io"8 "io"
8 "log/slog"9 "log/slog"
@@ -164,6 +165,40 @@ func TestTransportRejectsZeroLengthFrame(t *testing.T) {
164 }165 }
165 }166 }
166 167
168+func TestTransportRejectsFramesOverSharedMaximum(t *testing.T) {
169+ boundaryPayload := make([]byte, ports.MaxFrameLen-1)
170+ boundaryWire := &bytes.Buffer{}
171+ boundarySend := NewTransport(nil, boundaryWire, nil)
172+ if err := boundarySend.Send(ports.Frame{Type: ports.MsgOutput, Payload: boundaryPayload}); err != nil {
173+ t.Fatalf("boundary Send error = %v", err)
174+ }
175+ if got := binary.BigEndian.Uint32(boundaryWire.Bytes()[:frameHeaderLen]); got != ports.MaxFrameLen {
176+ t.Fatalf("boundary frame length = %d, want %d", got, ports.MaxFrameLen)
177+ }
178+ boundaryRecv := NewTransport(bytes.NewReader(boundaryWire.Bytes()), io.Discard, nil)
179+ boundaryFrame, boundaryErr := boundaryRecv.Recv()
180+ if boundaryErr != nil {
181+ t.Fatalf("boundary Recv error = %v", boundaryErr)
182+ }
183+ if boundaryFrame.Type != ports.MsgOutput || len(boundaryFrame.Payload) != len(boundaryPayload) {
184+ t.Fatalf("boundary frame = type %d, payload %d bytes; want type %d, payload %d bytes", boundaryFrame.Type, len(boundaryFrame.Payload), ports.MsgOutput, len(boundaryPayload))
185+ }
186+
187+ send := NewTransport(nil, io.Discard, nil)
188+ err := send.Send(ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, ports.MaxFrameLen)})
189+ if !errors.Is(err, ErrFrameTooLarge) {
190+ t.Fatalf("Send error = %v, want ErrFrameTooLarge", err)
191+ }
192+
193+ var header [frameHeaderLen]byte
194+ binary.BigEndian.PutUint32(header[:], ports.MaxFrameLen+1)
195+ recv := NewTransport(bytes.NewReader(header[:]), io.Discard, nil)
196+ _, err = recv.Recv()
197+ if !errors.Is(err, ErrFrameTooLarge) {
198+ t.Fatalf("Recv error = %v, want ErrFrameTooLarge", err)
199+ }
200+}
201+
167 func TestDialContextCanceledBeforeStart(t *testing.T) {202 func TestDialContextCanceledBeforeStart(t *testing.T) {
168 ctx, cancel := context.WithCancel(context.Background())203 ctx, cancel := context.WithCancel(context.Background())
169 cancel()204 cancel()
M internal/ports/frame.go +6 -1
@@ -1,7 +1,11 @@
11 package ports
22
33 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 20
4+const ProtocolVersion uint16 = 21
5+
6+// MaxFrameLen is the largest permitted frame length, including the type byte
7+// and excluding the four-byte length prefix.
8+const MaxFrameLen = 16 << 20
59
610 // MsgType identifies the kind of payload carried by a Frame.
711 type MsgType uint8
@@ -32,6 +36,7 @@ const (
3236 MsgSessions MsgType = 21
3337 MsgCommandResult MsgType = 22
3438 MsgSessionMeta MsgType = 23
39+ MsgScreenUpdate MsgType = 24
3540 )
3641
3742 // Frame is the unit of exchange over a Transport: a typed, length-delimited
@@ -1,7 +1,11 @@
1 package ports1 package ports
2 2
3 // ProtocolVersion is the current vev IPC wire protocol version.3 // ProtocolVersion is the current vev IPC wire protocol version.
4-const ProtocolVersion uint16 = 204+const ProtocolVersion uint16 = 21
5+
6+// MaxFrameLen is the largest permitted frame length, including the type byte
7+// and excluding the four-byte length prefix.
8+const MaxFrameLen = 16 << 20
5 9
6 // MsgType identifies the kind of payload carried by a Frame.10 // MsgType identifies the kind of payload carried by a Frame.
7 type MsgType uint811 type MsgType uint8
@@ -32,6 +36,7 @@ const (
32 MsgSessions MsgType = 2136 MsgSessions MsgType = 21
33 MsgCommandResult MsgType = 2237 MsgCommandResult MsgType = 22
34 MsgSessionMeta MsgType = 2338 MsgSessionMeta MsgType = 23
39+ MsgScreenUpdate MsgType = 24
35 )40 )
36 41
37 // Frame is the unit of exchange over a Transport: a typed, length-delimited42 // Frame is the unit of exchange over a Transport: a typed, length-delimited
M internal/ports/frame_test.go +12 -2
@@ -3,8 +3,17 @@ package ports
33 import "testing"
44
55 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 20 {
7- t.Fatalf("ProtocolVersion = %d, want 20", ProtocolVersion)
6+ if ProtocolVersion != 21 {
7+ t.Fatalf("ProtocolVersion = %d, want 21", ProtocolVersion)
8+ }
9+}
10+
11+func TestScreenUpdateMessageType(t *testing.T) {
12+ if MsgScreenUpdate != 24 {
13+ t.Fatalf("MsgScreenUpdate = %d, want 24", MsgScreenUpdate)
14+ }
15+ if MaxFrameLen != 16<<20 {
16+ t.Fatalf("MaxFrameLen = %d, want %d", MaxFrameLen, 16<<20)
817 }
918 }
1019
@@ -52,6 +61,7 @@ func TestMsgTypeUnique(t *testing.T) {
5261 {"MsgSessions", MsgSessions},
5362 {"MsgCommandResult", MsgCommandResult},
5463 {"MsgSessionMeta", MsgSessionMeta},
64+ {"MsgScreenUpdate", MsgScreenUpdate},
5565 }
5666
5767 seen := make(map[MsgType]string, len(tests))
@@ -3,8 +3,17 @@ package ports
3 import "testing"3 import "testing"
4 4
5 func TestProtocolVersion(t *testing.T) {5 func TestProtocolVersion(t *testing.T) {
6- if ProtocolVersion != 20 {6+ if ProtocolVersion != 21 {
7- t.Fatalf("ProtocolVersion = %d, want 20", ProtocolVersion)7+ t.Fatalf("ProtocolVersion = %d, want 21", ProtocolVersion)
8+ }
9+}
10+
11+func TestScreenUpdateMessageType(t *testing.T) {
12+ if MsgScreenUpdate != 24 {
13+ t.Fatalf("MsgScreenUpdate = %d, want 24", MsgScreenUpdate)
14+ }
15+ if MaxFrameLen != 16<<20 {
16+ t.Fatalf("MaxFrameLen = %d, want %d", MaxFrameLen, 16<<20)
8 }17 }
9 }18 }
10 19
@@ -52,6 +61,7 @@ func TestMsgTypeUnique(t *testing.T) {
52 {"MsgSessions", MsgSessions},61 {"MsgSessions", MsgSessions},
53 {"MsgCommandResult", MsgCommandResult},62 {"MsgCommandResult", MsgCommandResult},
54 {"MsgSessionMeta", MsgSessionMeta},63 {"MsgSessionMeta", MsgSessionMeta},
64+ {"MsgScreenUpdate", MsgScreenUpdate},
55 }65 }
56 66
57 seen := make(map[MsgType]string, len(tests))67 seen := make(map[MsgType]string, len(tests))
M internal/ports/wire.go +753 -0
@@ -10,6 +10,7 @@ import (
1010 "encoding/binary"
1111 "errors"
1212 "math"
13+ "unicode/utf8"
1314
1415 "github.com/bnema/vev/internal/domain"
1516 "github.com/bnema/vev/pkg/renderer"
@@ -1075,3 +1076,755 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
10751076 }
10761077 return Sessions{Sessions: sessions}, nil
10771078 }
1079+
1080+// ScreenUpdateKind selects whether an update replaces the complete screen or
1081+// applies a delta to the previously committed screen.
1082+type ScreenUpdateKind uint8
1083+
1084+const (
1085+ ScreenUpdateSnapshot ScreenUpdateKind = 1
1086+ ScreenUpdateDelta ScreenUpdateKind = 2
1087+)
1088+
1089+// ScreenCursor is the absolute cursor state carried by each screen update.
1090+type ScreenCursor struct {
1091+ Row, Col uint16
1092+ Style uint8
1093+ Visible bool
1094+ StyleSet bool
1095+}
1096+
1097+// ScreenScroll describes a full-width upward scroll applied before spans.
1098+type ScreenScroll struct {
1099+ Top, Height, Count uint16
1100+}
1101+
1102+// ScreenSpan identifies owned cells beginning at a horizontal position.
1103+type ScreenSpan struct {
1104+ Y, X uint16
1105+ Cells []renderer.Cell
1106+}
1107+
1108+// ScreenUpdate is the semantic structured screen message exchanged by proxy
1109+// daemons. Wire style tables and run IDs remain private to its codec.
1110+type ScreenUpdate struct {
1111+ BaseStateNum uint64
1112+ NewStateNum uint64
1113+ EchoAck uint64
1114+ Kind ScreenUpdateKind
1115+ Size domain.Size
1116+ Cursor ScreenCursor
1117+ Scroll *ScreenScroll
1118+ Spans []ScreenSpan
1119+}
1120+
1121+var (
1122+ // ErrInvalidScreenUpdate reports a malformed or non-canonical screen
1123+ // update. It is deliberately shared by marshal and unmarshal validation.
1124+ ErrInvalidScreenUpdate = errors.New("ports: invalid screen update")
1125+ // ErrScreenUpdateTooLarge reports a payload that cannot fit in a frame.
1126+ ErrScreenUpdateTooLarge = errors.New("ports: screen update too large")
1127+)
1128+
1129+const (
1130+ // MaxScreenCells bounds the area of a screen update.
1131+ MaxScreenCells = 1 << 18
1132+
1133+ screenHeaderLen = 40
1134+ screenStyleLen = 18
1135+ // MaxScreenSpans bounds the number of spans in a screen update.
1136+ MaxScreenSpans = 4096
1137+
1138+ // The frame length includes MsgScreenUpdate's type byte. The codec takes
1139+ // only the payload, so one byte is reserved here.
1140+ screenPayloadLimit = MaxFrameLen - 1
1141+
1142+ screenFlagCursorVisible = 1 << 0
1143+ screenFlagCursorStyleSet = 1 << 1
1144+ screenKnownCursorFlags = screenFlagCursorVisible | screenFlagCursorStyleSet
1145+
1146+ styleBoldBit = 1 << 0
1147+ styleItalicBit = 1 << 1
1148+ styleInverseBit = 1 << 2
1149+ styleDimBit = 1 << 3
1150+ styleUnderlineBit = 1 << 4
1151+ styleBlinkBit = 1 << 5
1152+ styleStrikethroughBit = 1 << 6
1153+ styleForegroundRGBBit = 1 << 7
1154+ styleBackgroundRGBBit = 1 << 8
1155+ styleUnderlineColorBit = 1 << 9
1156+ styleUnderlineColorRGBBit = 1 << 10
1157+ styleKnownBits = (1 << 11) - 1
1158+)
1159+
1160+// MarshalScreenUpdate encodes a canonical structured screen update.
1161+func MarshalScreenUpdate(m ScreenUpdate) ([]byte, error) {
1162+ if err := validateScreenUpdate(m); err != nil {
1163+ return nil, err
1164+ }
1165+
1166+ styles := make([]renderer.Style, 0)
1167+ styleIDs := make(map[renderer.Style]uint16)
1168+ for _, span := range m.Spans {
1169+ for _, cell := range span.Cells {
1170+ style := canonicalScreenStyle(cell.Style)
1171+ if _, ok := styleIDs[style]; ok {
1172+ continue
1173+ }
1174+ if len(styles) == math.MaxUint16 {
1175+ return nil, ErrInvalidScreenUpdate
1176+ }
1177+ styleIDs[style] = uint16(len(styles))
1178+ styles = append(styles, style)
1179+ }
1180+ }
1181+ w := screenWriter{b: make([]byte, 0, screenHeaderLen+len(styles)*screenStyleLen)}
1182+ w.u64(m.BaseStateNum)
1183+ w.u64(m.NewStateNum)
1184+ w.u64(m.EchoAck)
1185+ w.u8(uint8(m.Kind))
1186+ w.u16(uint16(m.Size.Cols))
1187+ w.u16(uint16(m.Size.Rows))
1188+ w.u16(m.Cursor.Row)
1189+ w.u16(m.Cursor.Col)
1190+ w.u8(m.Cursor.Style)
1191+ var cursorFlags uint8
1192+ if m.Cursor.Visible {
1193+ cursorFlags |= screenFlagCursorVisible
1194+ }
1195+ if m.Cursor.StyleSet {
1196+ cursorFlags |= screenFlagCursorStyleSet
1197+ }
1198+ w.u8(cursorFlags)
1199+ if m.Scroll != nil {
1200+ w.u8(1)
1201+ } else {
1202+ w.u8(0)
1203+ }
1204+ w.u16(uint16(len(styles)))
1205+ w.u16(uint16(len(m.Spans)))
1206+ if m.Scroll != nil {
1207+ w.u16(m.Scroll.Top)
1208+ w.u16(m.Scroll.Height)
1209+ w.u16(m.Scroll.Count)
1210+ }
1211+ for _, style := range styles {
1212+ appendScreenStyle(&w, style)
1213+ }
1214+ for _, span := range m.Spans {
1215+ w.u16(span.Y)
1216+ w.u16(span.X)
1217+ w.u16(uint16(len(span.Cells)))
1218+ runs := screenRunCount(span.Cells)
1219+ w.u16(uint16(runs))
1220+ for start := 0; start < len(span.Cells); {
1221+ style := canonicalScreenStyle(span.Cells[start].Style)
1222+ id := styleIDs[style]
1223+ end := start + 1
1224+ for end < len(span.Cells) && styleIDs[canonicalScreenStyle(span.Cells[end].Style)] == id {
1225+ end++
1226+ }
1227+ w.u16(id)
1228+ w.u16(uint16(end - start))
1229+ for _, cell := range span.Cells[start:end] {
1230+ if cell.Continuation {
1231+ w.uvarint(0)
1232+ } else {
1233+ w.uvarint(uint64(cell.Rune) + 1)
1234+ }
1235+ }
1236+ start = end
1237+ }
1238+ }
1239+ if w.tooLarge {
1240+ return nil, ErrScreenUpdateTooLarge
1241+ }
1242+ return w.b, nil
1243+}
1244+
1245+// UnmarshalScreenUpdate strictly decodes one screen-update payload. All
1246+// decoded slices own their data; no input-backed slice is retained.
1247+func UnmarshalScreenUpdate(data []byte) (ScreenUpdate, error) {
1248+ if len(data) > screenPayloadLimit {
1249+ return ScreenUpdate{}, ErrScreenUpdateTooLarge
1250+ }
1251+ if len(data) < screenHeaderLen {
1252+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1253+ }
1254+ r := screenReader{b: data}
1255+ var m ScreenUpdate
1256+ var err error
1257+ if m.BaseStateNum, err = r.u64(); err != nil {
1258+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1259+ }
1260+ if m.NewStateNum, err = r.u64(); err != nil {
1261+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1262+ }
1263+ if m.EchoAck, err = r.u64(); err != nil {
1264+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1265+ }
1266+ kind, err := r.u8()
1267+ if err != nil || (ScreenUpdateKind(kind) != ScreenUpdateSnapshot && ScreenUpdateKind(kind) != ScreenUpdateDelta) {
1268+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1269+ }
1270+ m.Kind = ScreenUpdateKind(kind)
1271+ cols, err := r.u16()
1272+ if err != nil {
1273+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1274+ }
1275+ rows, err := r.u16()
1276+ if err != nil || cols == 0 || rows == 0 || !screenAreaWithinLimit(domain.Size{Cols: int(cols), Rows: int(rows)}) {
1277+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1278+ }
1279+ m.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
1280+ if m.Cursor.Row, err = r.u16(); err != nil {
1281+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1282+ }
1283+ if m.Cursor.Col, err = r.u16(); err != nil {
1284+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1285+ }
1286+ if m.Cursor.Style, err = r.u8(); err != nil {
1287+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1288+ }
1289+ cursorFlags, err := r.u8()
1290+ if err != nil || cursorFlags&^uint8(screenKnownCursorFlags) != 0 || m.Cursor.Row >= rows || m.Cursor.Col >= cols {
1291+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1292+ }
1293+ m.Cursor.Visible = cursorFlags&screenFlagCursorVisible != 0
1294+ m.Cursor.StyleSet = cursorFlags&screenFlagCursorStyleSet != 0
1295+ if (!m.Cursor.StyleSet && m.Cursor.Style != 0) || (m.Cursor.StyleSet && m.Cursor.Style > 6) {
1296+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1297+ }
1298+ scrollPresent, err := r.u8()
1299+ if err != nil || scrollPresent > 1 {
1300+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1301+ }
1302+ styleCount, err := r.u16()
1303+ if err != nil || uint64(styleCount) > uint64(len(r.b))/screenStyleLen {
1304+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1305+ }
1306+ spanCount, err := r.u16()
1307+ if err != nil || spanCount > MaxScreenSpans || uint64(spanCount) > uint64(len(r.b))/8 {
1308+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1309+ }
1310+ if scrollPresent != 0 {
1311+ if m.Kind != ScreenUpdateDelta || len(r.b) < 6 {
1312+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1313+ }
1314+ m.Scroll = new(ScreenScroll)
1315+ if m.Scroll.Top, err = r.u16(); err != nil {
1316+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1317+ }
1318+ if m.Scroll.Height, err = r.u16(); err != nil {
1319+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1320+ }
1321+ if m.Scroll.Count, err = r.u16(); err != nil {
1322+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1323+ }
1324+ if !validScreenScroll(*m.Scroll, rows) {
1325+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1326+ }
1327+ } else {
1328+ m.Scroll = nil
1329+ }
1330+ // styleCount was checked against the bytes still available before this
1331+ // allocation. Each style is copied field-by-field below.
1332+ styles := make([]renderer.Style, styleCount)
1333+ seenStyles := make(map[renderer.Style]struct{}, styleCount)
1334+ for i := range styles {
1335+ style, ok := readScreenStyle(&r)
1336+ if !ok {
1337+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1338+ }
1339+ if _, duplicate := seenStyles[style]; duplicate {
1340+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1341+ }
1342+ seenStyles[style] = struct{}{}
1343+ styles[i] = style
1344+ }
1345+ m.Spans = make([]ScreenSpan, spanCount)
1346+ usedStyles := make([]bool, styleCount)
1347+ nextStyleID := uint16(0)
1348+ var previousEnd uint32
1349+ var previousY uint16
1350+ for i := range m.Spans {
1351+ y, errY := r.u16()
1352+ x, errX := r.u16()
1353+ cellCount, errCells := r.u16()
1354+ runCount, errRuns := r.u16()
1355+ if errY != nil || errX != nil || errCells != nil || errRuns != nil || cellCount == 0 || runCount == 0 || runCount > cellCount || uint64(runCount) > uint64(len(r.b))/4 {
1356+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1357+ }
1358+ if y >= rows || x >= cols || uint64(x)+uint64(cellCount) > uint64(cols) {
1359+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1360+ }
1361+ if i > 0 {
1362+ if y < previousY || (y == previousY && uint32(x) < previousEnd) {
1363+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1364+ }
1365+ }
1366+ // Ordered non-overlapping spans have at most one cell per screen cell,
1367+ // so the area check above bounds aggregate decoded cells before each
1368+ // per-span allocation. Every cell has at least one token and every run
1369+ // has a four-byte descriptor; check both before allocating the slice.
1370+ minimum := uint64(runCount)*4 + uint64(cellCount)
1371+ if minimum > uint64(len(r.b)) {
1372+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1373+ }
1374+ cells := make([]renderer.Cell, cellCount)
1375+ filled := 0
1376+ previousStyleID := uint16(math.MaxUint16)
1377+ for range runCount {
1378+ styleID, e1 := r.u16()
1379+ runCells, e2 := r.u16()
1380+ if e1 != nil || e2 != nil || runCells == 0 || styleID >= styleCount || styleID == previousStyleID || uint32(filled)+uint32(runCells) > uint32(cellCount) {
1381+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1382+ }
1383+ if !usedStyles[styleID] {
1384+ if styleID != nextStyleID {
1385+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1386+ }
1387+ nextStyleID++
1388+ usedStyles[styleID] = true
1389+ }
1390+ previousStyleID = styleID
1391+ for range runCells {
1392+ token, ok := r.uvarint()
1393+ if !ok {
1394+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1395+ }
1396+ if token == 0 {
1397+ cells[filled] = renderer.Cell{Style: styles[styleID], Continuation: true}
1398+ } else {
1399+ runeValue, valid := screenRune(token)
1400+ if !valid {
1401+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1402+ }
1403+ cells[filled] = renderer.Cell{Rune: runeValue, Style: styles[styleID]}
1404+ }
1405+ filled++
1406+ }
1407+ }
1408+ if filled != int(cellCount) {
1409+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1410+ }
1411+ m.Spans[i] = ScreenSpan{Y: y, X: x, Cells: cells}
1412+ previousY = y
1413+ previousEnd = uint32(x) + uint32(cellCount)
1414+ }
1415+ if len(r.b) != 0 || nextStyleID != styleCount {
1416+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1417+ }
1418+ if err := validateScreenUpdate(m); err != nil {
1419+ return ScreenUpdate{}, err
1420+ }
1421+ return m, nil
1422+}
1423+
1424+// ValidateScreenUpdate validates the shared semantic and canonical screen
1425+// update contract used by the wire codec and proxy consumers.
1426+func ValidateScreenUpdate(m ScreenUpdate) error {
1427+ return validateScreenUpdate(m)
1428+}
1429+
1430+func validateScreenUpdate(m ScreenUpdate) error {
1431+ switch m.Kind {
1432+ case ScreenUpdateSnapshot:
1433+ if m.BaseStateNum != 0 || m.NewStateNum == 0 {
1434+ return ErrInvalidScreenUpdate
1435+ }
1436+ case ScreenUpdateDelta:
1437+ if m.BaseStateNum == 0 || m.BaseStateNum == math.MaxUint64 || m.NewStateNum != m.BaseStateNum+1 {
1438+ return ErrInvalidScreenUpdate
1439+ }
1440+ default:
1441+ return ErrInvalidScreenUpdate
1442+ }
1443+ if m.Size.Cols <= 0 || m.Size.Rows <= 0 || m.Size.Cols > math.MaxUint16 || m.Size.Rows > math.MaxUint16 ||
1444+ !screenAreaWithinLimit(m.Size) || m.Cursor.Row >= uint16(m.Size.Rows) || m.Cursor.Col >= uint16(m.Size.Cols) {
1445+ return ErrInvalidScreenUpdate
1446+ }
1447+ if (!m.Cursor.StyleSet && m.Cursor.Style != 0) || (m.Cursor.StyleSet && m.Cursor.Style > 6) {
1448+ return ErrInvalidScreenUpdate
1449+ }
1450+ if m.Kind == ScreenUpdateSnapshot && m.Scroll != nil {
1451+ return ErrInvalidScreenUpdate
1452+ }
1453+ if m.Scroll != nil && !validScreenScroll(*m.Scroll, uint16(m.Size.Rows)) {
1454+ return ErrInvalidScreenUpdate
1455+ }
1456+ if len(m.Spans) > MaxScreenSpans || len(m.Spans) > math.MaxUint16 || (m.Kind == ScreenUpdateSnapshot && len(m.Spans) == 0) {
1457+ return ErrInvalidScreenUpdate
1458+ }
1459+ return validateScreenSpans(m)
1460+}
1461+
1462+func screenAreaWithinLimit(size domain.Size) bool {
1463+ return size.Cols > 0 && size.Rows > 0 && size.Rows <= MaxScreenCells/size.Cols
1464+}
1465+
1466+func validScreenScroll(scroll ScreenScroll, rows uint16) bool {
1467+ return scroll.Height != 0 && scroll.Count != 0 && scroll.Count < scroll.Height &&
1468+ scroll.Top < rows && uint64(scroll.Top)+uint64(scroll.Height) <= uint64(rows)
1469+}
1470+
1471+func validateScreenSpans(m ScreenUpdate) error {
1472+ cols, rows := uint32(m.Size.Cols), uint32(m.Size.Rows)
1473+ var previousY uint16
1474+ var previousEnd uint32
1475+ for i, span := range m.Spans {
1476+ if len(span.Cells) == 0 || len(span.Cells) > math.MaxUint16 || uint32(span.Y) >= rows || uint32(span.X) >= cols || uint64(span.X)+uint64(len(span.Cells)) > uint64(cols) {
1477+ return ErrInvalidScreenUpdate
1478+ }
1479+ if i > 0 && (span.Y < previousY || (span.Y == previousY && uint32(span.X) < previousEnd)) {
1480+ return ErrInvalidScreenUpdate
1481+ }
1482+ for _, cell := range span.Cells {
1483+ if (cell.Continuation && cell.Rune != 0) || (!cell.Continuation && !utf8.ValidRune(cell.Rune)) {
1484+ return ErrInvalidScreenUpdate
1485+ }
1486+ if err := validateScreenStyle(cell.Style); err != nil {
1487+ return err
1488+ }
1489+ }
1490+ previousY = span.Y
1491+ previousEnd = uint32(span.X) + uint32(len(span.Cells))
1492+ }
1493+ if m.Kind == ScreenUpdateSnapshot {
1494+ if len(m.Spans) != m.Size.Rows {
1495+ return ErrInvalidScreenUpdate
1496+ }
1497+ for y, span := range m.Spans {
1498+ if int(span.Y) != y || span.X != 0 || len(span.Cells) != m.Size.Cols {
1499+ return ErrInvalidScreenUpdate
1500+ }
1501+ }
1502+ }
1503+ return nil
1504+}
1505+
1506+func validateScreenStyle(style renderer.Style) error {
1507+ if style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 || style.UnderlineStyle > renderer.UnderlineDashed {
1508+ return ErrInvalidScreenUpdate
1509+ }
1510+ if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > math.MaxUint8 || style.ForegroundRGB != (renderer.RGB{})) {
1511+ return ErrInvalidScreenUpdate
1512+ }
1513+ if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > math.MaxUint8 || style.BackgroundRGB != (renderer.RGB{})) {
1514+ return ErrInvalidScreenUpdate
1515+ }
1516+ if style.HasUnderlineColorRGB && style.HasUnderlineColor {
1517+ return ErrInvalidScreenUpdate
1518+ } else if style.HasUnderlineColorRGB {
1519+ // The indexed value is inactive when RGB is selected.
1520+ } else if style.HasUnderlineColor {
1521+ if style.UnderlineColor < 0 || style.UnderlineColor > math.MaxUint8 || style.UnderlineColorRGB != (renderer.RGB{}) {
1522+ return ErrInvalidScreenUpdate
1523+ }
1524+ } else if style.UnderlineColorRGB != (renderer.RGB{}) {
1525+ return ErrInvalidScreenUpdate
1526+ }
1527+ return nil
1528+}
1529+
1530+func canonicalScreenStyle(style renderer.Style) renderer.Style {
1531+ if style.HasForegroundRGB {
1532+ style.Foreground = -1
1533+ } else {
1534+ style.ForegroundRGB = renderer.RGB{}
1535+ }
1536+ if style.HasBackgroundRGB {
1537+ style.Background = -1
1538+ } else {
1539+ style.BackgroundRGB = renderer.RGB{}
1540+ }
1541+ if style.HasUnderlineColorRGB {
1542+ style.UnderlineColor = -1
1543+ } else if style.HasUnderlineColor {
1544+ style.UnderlineColorRGB = renderer.RGB{}
1545+ } else {
1546+ style.UnderlineColor = -1
1547+ style.UnderlineColorRGB = renderer.RGB{}
1548+ }
1549+ return style
1550+}
1551+
1552+func screenStyleBits(style renderer.Style) uint16 {
1553+ var bits uint16
1554+ if style.Bold {
1555+ bits |= styleBoldBit
1556+ }
1557+ if style.Italic {
1558+ bits |= styleItalicBit
1559+ }
1560+ if style.Inverse {
1561+ bits |= styleInverseBit
1562+ }
1563+ if style.Attrs&renderer.AttrDim != 0 {
1564+ bits |= styleDimBit
1565+ }
1566+ if style.Attrs&renderer.AttrUnderline != 0 {
1567+ bits |= styleUnderlineBit
1568+ }
1569+ if style.Attrs&renderer.AttrBlink != 0 {
1570+ bits |= styleBlinkBit
1571+ }
1572+ if style.Attrs&renderer.AttrStrikethrough != 0 {
1573+ bits |= styleStrikethroughBit
1574+ }
1575+ if style.HasForegroundRGB {
1576+ bits |= styleForegroundRGBBit
1577+ }
1578+ if style.HasBackgroundRGB {
1579+ bits |= styleBackgroundRGBBit
1580+ }
1581+ if style.HasUnderlineColor {
1582+ bits |= styleUnderlineColorBit
1583+ }
1584+ if style.HasUnderlineColorRGB {
1585+ bits |= styleUnderlineColorRGBBit
1586+ }
1587+ return bits
1588+}
1589+
1590+func appendScreenStyle(w *screenWriter, style renderer.Style) {
1591+ style = canonicalScreenStyle(style)
1592+ w.u16(screenStyleBits(style))
1593+ w.u16(screenColorIndex(style.Foreground))
1594+ w.u16(screenColorIndex(style.Background))
1595+ w.u16(screenColorIndex(style.UnderlineColor))
1596+ w.u8(style.ForegroundRGB.R)
1597+ w.u8(style.ForegroundRGB.G)
1598+ w.u8(style.ForegroundRGB.B)
1599+ w.u8(style.BackgroundRGB.R)
1600+ w.u8(style.BackgroundRGB.G)
1601+ w.u8(style.BackgroundRGB.B)
1602+ w.u8(style.UnderlineColorRGB.R)
1603+ w.u8(style.UnderlineColorRGB.G)
1604+ w.u8(style.UnderlineColorRGB.B)
1605+ w.u8(uint8(style.UnderlineStyle))
1606+}
1607+
1608+func screenColorIndex(index int) uint16 {
1609+ if index < 0 {
1610+ return math.MaxUint16
1611+ }
1612+ return uint16(index)
1613+}
1614+
1615+func screenColorValue(index uint16) int {
1616+ if index == math.MaxUint16 {
1617+ return -1
1618+ }
1619+ return int(index)
1620+}
1621+
1622+func readScreenStyle(r *screenReader) (renderer.Style, bool) {
1623+ bits, ok := r.u16ok()
1624+ if !ok || bits&^uint16(styleKnownBits) != 0 {
1625+ return renderer.Style{}, false
1626+ }
1627+ foregroundIndex, ok := r.u16ok()
1628+ if !ok {
1629+ return renderer.Style{}, false
1630+ }
1631+ backgroundIndex, ok := r.u16ok()
1632+ if !ok {
1633+ return renderer.Style{}, false
1634+ }
1635+ underlineIndex, ok := r.u16ok()
1636+ if !ok {
1637+ return renderer.Style{}, false
1638+ }
1639+ fgRGB, ok := r.rgb()
1640+ if !ok {
1641+ return renderer.Style{}, false
1642+ }
1643+ bgRGB, ok := r.rgb()
1644+ if !ok {
1645+ return renderer.Style{}, false
1646+ }
1647+ underlineRGB, ok := r.rgb()
1648+ if !ok {
1649+ return renderer.Style{}, false
1650+ }
1651+ underlineStyle, ok := r.u8ok()
1652+ if !ok || underlineStyle > uint8(renderer.UnderlineDashed) {
1653+ return renderer.Style{}, false
1654+ }
1655+ style := renderer.Style{
1656+ Bold: bits&styleBoldBit != 0,
1657+ Italic: bits&styleItalicBit != 0,
1658+ Inverse: bits&styleInverseBit != 0,
1659+ UnderlineStyle: renderer.UnderlineStyle(underlineStyle),
1660+ Foreground: screenColorValue(foregroundIndex),
1661+ Background: screenColorValue(backgroundIndex),
1662+ HasForegroundRGB: bits&styleForegroundRGBBit != 0,
1663+ ForegroundRGB: fgRGB,
1664+ HasBackgroundRGB: bits&styleBackgroundRGBBit != 0,
1665+ BackgroundRGB: bgRGB,
1666+ HasUnderlineColor: bits&styleUnderlineColorBit != 0,
1667+ UnderlineColor: screenColorValue(underlineIndex),
1668+ HasUnderlineColorRGB: bits&styleUnderlineColorRGBBit != 0,
1669+ UnderlineColorRGB: underlineRGB,
1670+ }
1671+ if style.HasUnderlineColor && style.HasUnderlineColorRGB {
1672+ return renderer.Style{}, false
1673+ }
1674+ style.Attrs = 0
1675+ if bits&styleDimBit != 0 {
1676+ style.Attrs |= renderer.AttrDim
1677+ }
1678+ if bits&styleUnderlineBit != 0 {
1679+ style.Attrs |= renderer.AttrUnderline
1680+ }
1681+ if bits&styleBlinkBit != 0 {
1682+ style.Attrs |= renderer.AttrBlink
1683+ }
1684+ if bits&styleStrikethroughBit != 0 {
1685+ style.Attrs |= renderer.AttrStrikethrough
1686+ }
1687+ if style.HasForegroundRGB {
1688+ if foregroundIndex != math.MaxUint16 {
1689+ return renderer.Style{}, false
1690+ }
1691+ style.Foreground = -1
1692+ } else if (foregroundIndex != math.MaxUint16 && foregroundIndex > math.MaxUint8) || fgRGB != (renderer.RGB{}) {
1693+ return renderer.Style{}, false
1694+ }
1695+ if style.HasBackgroundRGB {
1696+ if backgroundIndex != math.MaxUint16 {
1697+ return renderer.Style{}, false
1698+ }
1699+ style.Background = -1
1700+ } else if (backgroundIndex != math.MaxUint16 && backgroundIndex > math.MaxUint8) || bgRGB != (renderer.RGB{}) {
1701+ return renderer.Style{}, false
1702+ }
1703+ if style.HasUnderlineColorRGB {
1704+ if underlineIndex != math.MaxUint16 {
1705+ return renderer.Style{}, false
1706+ }
1707+ style.UnderlineColor = -1
1708+ } else if style.HasUnderlineColor {
1709+ if underlineIndex == math.MaxUint16 || underlineIndex > math.MaxUint8 || underlineRGB != (renderer.RGB{}) {
1710+ return renderer.Style{}, false
1711+ }
1712+ } else if underlineIndex != math.MaxUint16 || underlineRGB != (renderer.RGB{}) {
1713+ return renderer.Style{}, false
1714+ }
1715+ return style, true
1716+}
1717+
1718+func screenRunCount(cells []renderer.Cell) int {
1719+ if len(cells) == 0 {
1720+ return 0
1721+ }
1722+ n := 1
1723+ previous := canonicalScreenStyle(cells[0].Style)
1724+ for _, cell := range cells[1:] {
1725+ style := canonicalScreenStyle(cell.Style)
1726+ if style != previous {
1727+ n++
1728+ previous = style
1729+ }
1730+ }
1731+ return n
1732+}
1733+
1734+func screenRune(token uint64) (rune, bool) {
1735+ if token == 0 || token > uint64(utf8.MaxRune)+1 {
1736+ return 0, false
1737+ }
1738+ r := rune(token - 1)
1739+ return r, utf8.ValidRune(r)
1740+}
1741+
1742+type screenWriter struct {
1743+ b []byte
1744+ tooLarge bool
1745+}
1746+
1747+func (w *screenWriter) append(p ...byte) {
1748+ if w.tooLarge || len(w.b) > screenPayloadLimit-len(p) {
1749+ w.tooLarge = true
1750+ return
1751+ }
1752+ w.b = append(w.b, p...)
1753+}
1754+func (w *screenWriter) u8(v uint8) { w.append(v) }
1755+func (w *screenWriter) u16(v uint16) { w.append(byte(v>>8), byte(v)) }
1756+func (w *screenWriter) u64(v uint64) {
1757+ var b [8]byte
1758+ binary.BigEndian.PutUint64(b[:], v)
1759+ w.append(b[:]...)
1760+}
1761+func (w *screenWriter) uvarint(v uint64) {
1762+ var b [10]byte
1763+ n := binary.PutUvarint(b[:], v)
1764+ w.append(b[:n]...)
1765+}
1766+
1767+type screenReader struct{ b []byte }
1768+
1769+func (r *screenReader) take(n int) ([]byte, bool) {
1770+ if n < 0 || len(r.b) < n {
1771+ return nil, false
1772+ }
1773+ p := r.b[:n]
1774+ r.b = r.b[n:]
1775+ return p, true
1776+}
1777+func (r *screenReader) u8() (uint8, error) {
1778+ v, ok := r.u8ok()
1779+ if !ok {
1780+ return 0, ErrInvalidScreenUpdate
1781+ }
1782+ return v, nil
1783+}
1784+func (r *screenReader) u8ok() (uint8, bool) {
1785+ p, ok := r.take(1)
1786+ if !ok {
1787+ return 0, false
1788+ }
1789+ return p[0], true
1790+}
1791+func (r *screenReader) u16() (uint16, error) {
1792+ p, ok := r.take(2)
1793+ if !ok {
1794+ return 0, ErrInvalidScreenUpdate
1795+ }
1796+ return binary.BigEndian.Uint16(p), nil
1797+}
1798+func (r *screenReader) u16ok() (uint16, bool) {
1799+ p, ok := r.take(2)
1800+ if !ok {
1801+ return 0, false
1802+ }
1803+ return binary.BigEndian.Uint16(p), true
1804+}
1805+func (r *screenReader) u64() (uint64, error) {
1806+ p, ok := r.take(8)
1807+ if !ok {
1808+ return 0, ErrInvalidScreenUpdate
1809+ }
1810+ return binary.BigEndian.Uint64(p), nil
1811+}
1812+func (r *screenReader) rgb() (renderer.RGB, bool) {
1813+ p, ok := r.take(3)
1814+ if !ok {
1815+ return renderer.RGB{}, false
1816+ }
1817+ return renderer.RGB{R: p[0], G: p[1], B: p[2]}, true
1818+}
1819+func (r *screenReader) uvarint() (uint64, bool) {
1820+ v, n := binary.Uvarint(r.b)
1821+ if n <= 0 {
1822+ return 0, false
1823+ }
1824+ var canonical [binary.MaxVarintLen64]byte
1825+ if binary.PutUvarint(canonical[:], v) != n {
1826+ return 0, false
1827+ }
1828+ r.b = r.b[n:]
1829+ return v, true
1830+}
@@ -10,6 +10,7 @@ import (
10 "encoding/binary"10 "encoding/binary"
11 "errors"11 "errors"
12 "math"12 "math"
13+ "unicode/utf8"
13 14
14 "github.com/bnema/vev/internal/domain"15 "github.com/bnema/vev/internal/domain"
15 "github.com/bnema/vev/pkg/renderer"16 "github.com/bnema/vev/pkg/renderer"
@@ -1075,3 +1076,755 @@ func UnmarshalSessions(b []byte) (Sessions, error) {
1075 }1076 }
1076 return Sessions{Sessions: sessions}, nil1077 return Sessions{Sessions: sessions}, nil
1077 }1078 }
1079+
1080+// ScreenUpdateKind selects whether an update replaces the complete screen or
1081+// applies a delta to the previously committed screen.
1082+type ScreenUpdateKind uint8
1083+
1084+const (
1085+ ScreenUpdateSnapshot ScreenUpdateKind = 1
1086+ ScreenUpdateDelta ScreenUpdateKind = 2
1087+)
1088+
1089+// ScreenCursor is the absolute cursor state carried by each screen update.
1090+type ScreenCursor struct {
1091+ Row, Col uint16
1092+ Style uint8
1093+ Visible bool
1094+ StyleSet bool
1095+}
1096+
1097+// ScreenScroll describes a full-width upward scroll applied before spans.
1098+type ScreenScroll struct {
1099+ Top, Height, Count uint16
1100+}
1101+
1102+// ScreenSpan identifies owned cells beginning at a horizontal position.
1103+type ScreenSpan struct {
1104+ Y, X uint16
1105+ Cells []renderer.Cell
1106+}
1107+
1108+// ScreenUpdate is the semantic structured screen message exchanged by proxy
1109+// daemons. Wire style tables and run IDs remain private to its codec.
1110+type ScreenUpdate struct {
1111+ BaseStateNum uint64
1112+ NewStateNum uint64
1113+ EchoAck uint64
1114+ Kind ScreenUpdateKind
1115+ Size domain.Size
1116+ Cursor ScreenCursor
1117+ Scroll *ScreenScroll
1118+ Spans []ScreenSpan
1119+}
1120+
1121+var (
1122+ // ErrInvalidScreenUpdate reports a malformed or non-canonical screen
1123+ // update. It is deliberately shared by marshal and unmarshal validation.
1124+ ErrInvalidScreenUpdate = errors.New("ports: invalid screen update")
1125+ // ErrScreenUpdateTooLarge reports a payload that cannot fit in a frame.
1126+ ErrScreenUpdateTooLarge = errors.New("ports: screen update too large")
1127+)
1128+
1129+const (
1130+ // MaxScreenCells bounds the area of a screen update.
1131+ MaxScreenCells = 1 << 18
1132+
1133+ screenHeaderLen = 40
1134+ screenStyleLen = 18
1135+ // MaxScreenSpans bounds the number of spans in a screen update.
1136+ MaxScreenSpans = 4096
1137+
1138+ // The frame length includes MsgScreenUpdate's type byte. The codec takes
1139+ // only the payload, so one byte is reserved here.
1140+ screenPayloadLimit = MaxFrameLen - 1
1141+
1142+ screenFlagCursorVisible = 1 << 0
1143+ screenFlagCursorStyleSet = 1 << 1
1144+ screenKnownCursorFlags = screenFlagCursorVisible | screenFlagCursorStyleSet
1145+
1146+ styleBoldBit = 1 << 0
1147+ styleItalicBit = 1 << 1
1148+ styleInverseBit = 1 << 2
1149+ styleDimBit = 1 << 3
1150+ styleUnderlineBit = 1 << 4
1151+ styleBlinkBit = 1 << 5
1152+ styleStrikethroughBit = 1 << 6
1153+ styleForegroundRGBBit = 1 << 7
1154+ styleBackgroundRGBBit = 1 << 8
1155+ styleUnderlineColorBit = 1 << 9
1156+ styleUnderlineColorRGBBit = 1 << 10
1157+ styleKnownBits = (1 << 11) - 1
1158+)
1159+
1160+// MarshalScreenUpdate encodes a canonical structured screen update.
1161+func MarshalScreenUpdate(m ScreenUpdate) ([]byte, error) {
1162+ if err := validateScreenUpdate(m); err != nil {
1163+ return nil, err
1164+ }
1165+
1166+ styles := make([]renderer.Style, 0)
1167+ styleIDs := make(map[renderer.Style]uint16)
1168+ for _, span := range m.Spans {
1169+ for _, cell := range span.Cells {
1170+ style := canonicalScreenStyle(cell.Style)
1171+ if _, ok := styleIDs[style]; ok {
1172+ continue
1173+ }
1174+ if len(styles) == math.MaxUint16 {
1175+ return nil, ErrInvalidScreenUpdate
1176+ }
1177+ styleIDs[style] = uint16(len(styles))
1178+ styles = append(styles, style)
1179+ }
1180+ }
1181+ w := screenWriter{b: make([]byte, 0, screenHeaderLen+len(styles)*screenStyleLen)}
1182+ w.u64(m.BaseStateNum)
1183+ w.u64(m.NewStateNum)
1184+ w.u64(m.EchoAck)
1185+ w.u8(uint8(m.Kind))
1186+ w.u16(uint16(m.Size.Cols))
1187+ w.u16(uint16(m.Size.Rows))
1188+ w.u16(m.Cursor.Row)
1189+ w.u16(m.Cursor.Col)
1190+ w.u8(m.Cursor.Style)
1191+ var cursorFlags uint8
1192+ if m.Cursor.Visible {
1193+ cursorFlags |= screenFlagCursorVisible
1194+ }
1195+ if m.Cursor.StyleSet {
1196+ cursorFlags |= screenFlagCursorStyleSet
1197+ }
1198+ w.u8(cursorFlags)
1199+ if m.Scroll != nil {
1200+ w.u8(1)
1201+ } else {
1202+ w.u8(0)
1203+ }
1204+ w.u16(uint16(len(styles)))
1205+ w.u16(uint16(len(m.Spans)))
1206+ if m.Scroll != nil {
1207+ w.u16(m.Scroll.Top)
1208+ w.u16(m.Scroll.Height)
1209+ w.u16(m.Scroll.Count)
1210+ }
1211+ for _, style := range styles {
1212+ appendScreenStyle(&w, style)
1213+ }
1214+ for _, span := range m.Spans {
1215+ w.u16(span.Y)
1216+ w.u16(span.X)
1217+ w.u16(uint16(len(span.Cells)))
1218+ runs := screenRunCount(span.Cells)
1219+ w.u16(uint16(runs))
1220+ for start := 0; start < len(span.Cells); {
1221+ style := canonicalScreenStyle(span.Cells[start].Style)
1222+ id := styleIDs[style]
1223+ end := start + 1
1224+ for end < len(span.Cells) && styleIDs[canonicalScreenStyle(span.Cells[end].Style)] == id {
1225+ end++
1226+ }
1227+ w.u16(id)
1228+ w.u16(uint16(end - start))
1229+ for _, cell := range span.Cells[start:end] {
1230+ if cell.Continuation {
1231+ w.uvarint(0)
1232+ } else {
1233+ w.uvarint(uint64(cell.Rune) + 1)
1234+ }
1235+ }
1236+ start = end
1237+ }
1238+ }
1239+ if w.tooLarge {
1240+ return nil, ErrScreenUpdateTooLarge
1241+ }
1242+ return w.b, nil
1243+}
1244+
1245+// UnmarshalScreenUpdate strictly decodes one screen-update payload. All
1246+// decoded slices own their data; no input-backed slice is retained.
1247+func UnmarshalScreenUpdate(data []byte) (ScreenUpdate, error) {
1248+ if len(data) > screenPayloadLimit {
1249+ return ScreenUpdate{}, ErrScreenUpdateTooLarge
1250+ }
1251+ if len(data) < screenHeaderLen {
1252+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1253+ }
1254+ r := screenReader{b: data}
1255+ var m ScreenUpdate
1256+ var err error
1257+ if m.BaseStateNum, err = r.u64(); err != nil {
1258+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1259+ }
1260+ if m.NewStateNum, err = r.u64(); err != nil {
1261+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1262+ }
1263+ if m.EchoAck, err = r.u64(); err != nil {
1264+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1265+ }
1266+ kind, err := r.u8()
1267+ if err != nil || (ScreenUpdateKind(kind) != ScreenUpdateSnapshot && ScreenUpdateKind(kind) != ScreenUpdateDelta) {
1268+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1269+ }
1270+ m.Kind = ScreenUpdateKind(kind)
1271+ cols, err := r.u16()
1272+ if err != nil {
1273+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1274+ }
1275+ rows, err := r.u16()
1276+ if err != nil || cols == 0 || rows == 0 || !screenAreaWithinLimit(domain.Size{Cols: int(cols), Rows: int(rows)}) {
1277+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1278+ }
1279+ m.Size = domain.Size{Cols: int(cols), Rows: int(rows)}
1280+ if m.Cursor.Row, err = r.u16(); err != nil {
1281+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1282+ }
1283+ if m.Cursor.Col, err = r.u16(); err != nil {
1284+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1285+ }
1286+ if m.Cursor.Style, err = r.u8(); err != nil {
1287+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1288+ }
1289+ cursorFlags, err := r.u8()
1290+ if err != nil || cursorFlags&^uint8(screenKnownCursorFlags) != 0 || m.Cursor.Row >= rows || m.Cursor.Col >= cols {
1291+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1292+ }
1293+ m.Cursor.Visible = cursorFlags&screenFlagCursorVisible != 0
1294+ m.Cursor.StyleSet = cursorFlags&screenFlagCursorStyleSet != 0
1295+ if (!m.Cursor.StyleSet && m.Cursor.Style != 0) || (m.Cursor.StyleSet && m.Cursor.Style > 6) {
1296+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1297+ }
1298+ scrollPresent, err := r.u8()
1299+ if err != nil || scrollPresent > 1 {
1300+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1301+ }
1302+ styleCount, err := r.u16()
1303+ if err != nil || uint64(styleCount) > uint64(len(r.b))/screenStyleLen {
1304+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1305+ }
1306+ spanCount, err := r.u16()
1307+ if err != nil || spanCount > MaxScreenSpans || uint64(spanCount) > uint64(len(r.b))/8 {
1308+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1309+ }
1310+ if scrollPresent != 0 {
1311+ if m.Kind != ScreenUpdateDelta || len(r.b) < 6 {
1312+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1313+ }
1314+ m.Scroll = new(ScreenScroll)
1315+ if m.Scroll.Top, err = r.u16(); err != nil {
1316+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1317+ }
1318+ if m.Scroll.Height, err = r.u16(); err != nil {
1319+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1320+ }
1321+ if m.Scroll.Count, err = r.u16(); err != nil {
1322+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1323+ }
1324+ if !validScreenScroll(*m.Scroll, rows) {
1325+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1326+ }
1327+ } else {
1328+ m.Scroll = nil
1329+ }
1330+ // styleCount was checked against the bytes still available before this
1331+ // allocation. Each style is copied field-by-field below.
1332+ styles := make([]renderer.Style, styleCount)
1333+ seenStyles := make(map[renderer.Style]struct{}, styleCount)
1334+ for i := range styles {
1335+ style, ok := readScreenStyle(&r)
1336+ if !ok {
1337+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1338+ }
1339+ if _, duplicate := seenStyles[style]; duplicate {
1340+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1341+ }
1342+ seenStyles[style] = struct{}{}
1343+ styles[i] = style
1344+ }
1345+ m.Spans = make([]ScreenSpan, spanCount)
1346+ usedStyles := make([]bool, styleCount)
1347+ nextStyleID := uint16(0)
1348+ var previousEnd uint32
1349+ var previousY uint16
1350+ for i := range m.Spans {
1351+ y, errY := r.u16()
1352+ x, errX := r.u16()
1353+ cellCount, errCells := r.u16()
1354+ runCount, errRuns := r.u16()
1355+ if errY != nil || errX != nil || errCells != nil || errRuns != nil || cellCount == 0 || runCount == 0 || runCount > cellCount || uint64(runCount) > uint64(len(r.b))/4 {
1356+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1357+ }
1358+ if y >= rows || x >= cols || uint64(x)+uint64(cellCount) > uint64(cols) {
1359+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1360+ }
1361+ if i > 0 {
1362+ if y < previousY || (y == previousY && uint32(x) < previousEnd) {
1363+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1364+ }
1365+ }
1366+ // Ordered non-overlapping spans have at most one cell per screen cell,
1367+ // so the area check above bounds aggregate decoded cells before each
1368+ // per-span allocation. Every cell has at least one token and every run
1369+ // has a four-byte descriptor; check both before allocating the slice.
1370+ minimum := uint64(runCount)*4 + uint64(cellCount)
1371+ if minimum > uint64(len(r.b)) {
1372+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1373+ }
1374+ cells := make([]renderer.Cell, cellCount)
1375+ filled := 0
1376+ previousStyleID := uint16(math.MaxUint16)
1377+ for range runCount {
1378+ styleID, e1 := r.u16()
1379+ runCells, e2 := r.u16()
1380+ if e1 != nil || e2 != nil || runCells == 0 || styleID >= styleCount || styleID == previousStyleID || uint32(filled)+uint32(runCells) > uint32(cellCount) {
1381+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1382+ }
1383+ if !usedStyles[styleID] {
1384+ if styleID != nextStyleID {
1385+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1386+ }
1387+ nextStyleID++
1388+ usedStyles[styleID] = true
1389+ }
1390+ previousStyleID = styleID
1391+ for range runCells {
1392+ token, ok := r.uvarint()
1393+ if !ok {
1394+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1395+ }
1396+ if token == 0 {
1397+ cells[filled] = renderer.Cell{Style: styles[styleID], Continuation: true}
1398+ } else {
1399+ runeValue, valid := screenRune(token)
1400+ if !valid {
1401+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1402+ }
1403+ cells[filled] = renderer.Cell{Rune: runeValue, Style: styles[styleID]}
1404+ }
1405+ filled++
1406+ }
1407+ }
1408+ if filled != int(cellCount) {
1409+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1410+ }
1411+ m.Spans[i] = ScreenSpan{Y: y, X: x, Cells: cells}
1412+ previousY = y
1413+ previousEnd = uint32(x) + uint32(cellCount)
1414+ }
1415+ if len(r.b) != 0 || nextStyleID != styleCount {
1416+ return ScreenUpdate{}, ErrInvalidScreenUpdate
1417+ }
1418+ if err := validateScreenUpdate(m); err != nil {
1419+ return ScreenUpdate{}, err
1420+ }
1421+ return m, nil
1422+}
1423+
1424+// ValidateScreenUpdate validates the shared semantic and canonical screen
1425+// update contract used by the wire codec and proxy consumers.
1426+func ValidateScreenUpdate(m ScreenUpdate) error {
1427+ return validateScreenUpdate(m)
1428+}
1429+
1430+func validateScreenUpdate(m ScreenUpdate) error {
1431+ switch m.Kind {
1432+ case ScreenUpdateSnapshot:
1433+ if m.BaseStateNum != 0 || m.NewStateNum == 0 {
1434+ return ErrInvalidScreenUpdate
1435+ }
1436+ case ScreenUpdateDelta:
1437+ if m.BaseStateNum == 0 || m.BaseStateNum == math.MaxUint64 || m.NewStateNum != m.BaseStateNum+1 {
1438+ return ErrInvalidScreenUpdate
1439+ }
1440+ default:
1441+ return ErrInvalidScreenUpdate
1442+ }
1443+ if m.Size.Cols <= 0 || m.Size.Rows <= 0 || m.Size.Cols > math.MaxUint16 || m.Size.Rows > math.MaxUint16 ||
1444+ !screenAreaWithinLimit(m.Size) || m.Cursor.Row >= uint16(m.Size.Rows) || m.Cursor.Col >= uint16(m.Size.Cols) {
1445+ return ErrInvalidScreenUpdate
1446+ }
1447+ if (!m.Cursor.StyleSet && m.Cursor.Style != 0) || (m.Cursor.StyleSet && m.Cursor.Style > 6) {
1448+ return ErrInvalidScreenUpdate
1449+ }
1450+ if m.Kind == ScreenUpdateSnapshot && m.Scroll != nil {
1451+ return ErrInvalidScreenUpdate
1452+ }
1453+ if m.Scroll != nil && !validScreenScroll(*m.Scroll, uint16(m.Size.Rows)) {
1454+ return ErrInvalidScreenUpdate
1455+ }
1456+ if len(m.Spans) > MaxScreenSpans || len(m.Spans) > math.MaxUint16 || (m.Kind == ScreenUpdateSnapshot && len(m.Spans) == 0) {
1457+ return ErrInvalidScreenUpdate
1458+ }
1459+ return validateScreenSpans(m)
1460+}
1461+
1462+func screenAreaWithinLimit(size domain.Size) bool {
1463+ return size.Cols > 0 && size.Rows > 0 && size.Rows <= MaxScreenCells/size.Cols
1464+}
1465+
1466+func validScreenScroll(scroll ScreenScroll, rows uint16) bool {
1467+ return scroll.Height != 0 && scroll.Count != 0 && scroll.Count < scroll.Height &&
1468+ scroll.Top < rows && uint64(scroll.Top)+uint64(scroll.Height) <= uint64(rows)
1469+}
1470+
1471+func validateScreenSpans(m ScreenUpdate) error {
1472+ cols, rows := uint32(m.Size.Cols), uint32(m.Size.Rows)
1473+ var previousY uint16
1474+ var previousEnd uint32
1475+ for i, span := range m.Spans {
1476+ if len(span.Cells) == 0 || len(span.Cells) > math.MaxUint16 || uint32(span.Y) >= rows || uint32(span.X) >= cols || uint64(span.X)+uint64(len(span.Cells)) > uint64(cols) {
1477+ return ErrInvalidScreenUpdate
1478+ }
1479+ if i > 0 && (span.Y < previousY || (span.Y == previousY && uint32(span.X) < previousEnd)) {
1480+ return ErrInvalidScreenUpdate
1481+ }
1482+ for _, cell := range span.Cells {
1483+ if (cell.Continuation && cell.Rune != 0) || (!cell.Continuation && !utf8.ValidRune(cell.Rune)) {
1484+ return ErrInvalidScreenUpdate
1485+ }
1486+ if err := validateScreenStyle(cell.Style); err != nil {
1487+ return err
1488+ }
1489+ }
1490+ previousY = span.Y
1491+ previousEnd = uint32(span.X) + uint32(len(span.Cells))
1492+ }
1493+ if m.Kind == ScreenUpdateSnapshot {
1494+ if len(m.Spans) != m.Size.Rows {
1495+ return ErrInvalidScreenUpdate
1496+ }
1497+ for y, span := range m.Spans {
1498+ if int(span.Y) != y || span.X != 0 || len(span.Cells) != m.Size.Cols {
1499+ return ErrInvalidScreenUpdate
1500+ }
1501+ }
1502+ }
1503+ return nil
1504+}
1505+
1506+func validateScreenStyle(style renderer.Style) error {
1507+ if style.Attrs&^(renderer.AttrDim|renderer.AttrUnderline|renderer.AttrBlink|renderer.AttrStrikethrough) != 0 || style.UnderlineStyle > renderer.UnderlineDashed {
1508+ return ErrInvalidScreenUpdate
1509+ }
1510+ if !style.HasForegroundRGB && (style.Foreground < -1 || style.Foreground > math.MaxUint8 || style.ForegroundRGB != (renderer.RGB{})) {
1511+ return ErrInvalidScreenUpdate
1512+ }
1513+ if !style.HasBackgroundRGB && (style.Background < -1 || style.Background > math.MaxUint8 || style.BackgroundRGB != (renderer.RGB{})) {
1514+ return ErrInvalidScreenUpdate
1515+ }
1516+ if style.HasUnderlineColorRGB && style.HasUnderlineColor {
1517+ return ErrInvalidScreenUpdate
1518+ } else if style.HasUnderlineColorRGB {
1519+ // The indexed value is inactive when RGB is selected.
1520+ } else if style.HasUnderlineColor {
1521+ if style.UnderlineColor < 0 || style.UnderlineColor > math.MaxUint8 || style.UnderlineColorRGB != (renderer.RGB{}) {
1522+ return ErrInvalidScreenUpdate
1523+ }
1524+ } else if style.UnderlineColorRGB != (renderer.RGB{}) {
1525+ return ErrInvalidScreenUpdate
1526+ }
1527+ return nil
1528+}
1529+
1530+func canonicalScreenStyle(style renderer.Style) renderer.Style {
1531+ if style.HasForegroundRGB {
1532+ style.Foreground = -1
1533+ } else {
1534+ style.ForegroundRGB = renderer.RGB{}
1535+ }
1536+ if style.HasBackgroundRGB {
1537+ style.Background = -1
1538+ } else {
1539+ style.BackgroundRGB = renderer.RGB{}
1540+ }
1541+ if style.HasUnderlineColorRGB {
1542+ style.UnderlineColor = -1
1543+ } else if style.HasUnderlineColor {
1544+ style.UnderlineColorRGB = renderer.RGB{}
1545+ } else {
1546+ style.UnderlineColor = -1
1547+ style.UnderlineColorRGB = renderer.RGB{}
1548+ }
1549+ return style
1550+}
1551+
1552+func screenStyleBits(style renderer.Style) uint16 {
1553+ var bits uint16
1554+ if style.Bold {
1555+ bits |= styleBoldBit
1556+ }
1557+ if style.Italic {
1558+ bits |= styleItalicBit
1559+ }
1560+ if style.Inverse {
1561+ bits |= styleInverseBit
1562+ }
1563+ if style.Attrs&renderer.AttrDim != 0 {
1564+ bits |= styleDimBit
1565+ }
1566+ if style.Attrs&renderer.AttrUnderline != 0 {
1567+ bits |= styleUnderlineBit
1568+ }
1569+ if style.Attrs&renderer.AttrBlink != 0 {
1570+ bits |= styleBlinkBit
1571+ }
1572+ if style.Attrs&renderer.AttrStrikethrough != 0 {
1573+ bits |= styleStrikethroughBit
1574+ }
1575+ if style.HasForegroundRGB {
1576+ bits |= styleForegroundRGBBit
1577+ }
1578+ if style.HasBackgroundRGB {
1579+ bits |= styleBackgroundRGBBit
1580+ }
1581+ if style.HasUnderlineColor {
1582+ bits |= styleUnderlineColorBit
1583+ }
1584+ if style.HasUnderlineColorRGB {
1585+ bits |= styleUnderlineColorRGBBit
1586+ }
1587+ return bits
1588+}
1589+
1590+func appendScreenStyle(w *screenWriter, style renderer.Style) {
1591+ style = canonicalScreenStyle(style)
1592+ w.u16(screenStyleBits(style))
1593+ w.u16(screenColorIndex(style.Foreground))
1594+ w.u16(screenColorIndex(style.Background))
1595+ w.u16(screenColorIndex(style.UnderlineColor))
1596+ w.u8(style.ForegroundRGB.R)
1597+ w.u8(style.ForegroundRGB.G)
1598+ w.u8(style.ForegroundRGB.B)
1599+ w.u8(style.BackgroundRGB.R)
1600+ w.u8(style.BackgroundRGB.G)
1601+ w.u8(style.BackgroundRGB.B)
1602+ w.u8(style.UnderlineColorRGB.R)
1603+ w.u8(style.UnderlineColorRGB.G)
1604+ w.u8(style.UnderlineColorRGB.B)
1605+ w.u8(uint8(style.UnderlineStyle))
1606+}
1607+
1608+func screenColorIndex(index int) uint16 {
1609+ if index < 0 {
1610+ return math.MaxUint16
1611+ }
1612+ return uint16(index)
1613+}
1614+
1615+func screenColorValue(index uint16) int {
1616+ if index == math.MaxUint16 {
1617+ return -1
1618+ }
1619+ return int(index)
1620+}
1621+
1622+func readScreenStyle(r *screenReader) (renderer.Style, bool) {
1623+ bits, ok := r.u16ok()
1624+ if !ok || bits&^uint16(styleKnownBits) != 0 {
1625+ return renderer.Style{}, false
1626+ }
1627+ foregroundIndex, ok := r.u16ok()
1628+ if !ok {
1629+ return renderer.Style{}, false
1630+ }
1631+ backgroundIndex, ok := r.u16ok()
1632+ if !ok {
1633+ return renderer.Style{}, false
1634+ }
1635+ underlineIndex, ok := r.u16ok()
1636+ if !ok {
1637+ return renderer.Style{}, false
1638+ }
1639+ fgRGB, ok := r.rgb()
1640+ if !ok {
1641+ return renderer.Style{}, false
1642+ }
1643+ bgRGB, ok := r.rgb()
1644+ if !ok {
1645+ return renderer.Style{}, false
1646+ }
1647+ underlineRGB, ok := r.rgb()
1648+ if !ok {
1649+ return renderer.Style{}, false
1650+ }
1651+ underlineStyle, ok := r.u8ok()
1652+ if !ok || underlineStyle > uint8(renderer.UnderlineDashed) {
1653+ return renderer.Style{}, false
1654+ }
1655+ style := renderer.Style{
1656+ Bold: bits&styleBoldBit != 0,
1657+ Italic: bits&styleItalicBit != 0,
1658+ Inverse: bits&styleInverseBit != 0,
1659+ UnderlineStyle: renderer.UnderlineStyle(underlineStyle),
1660+ Foreground: screenColorValue(foregroundIndex),
1661+ Background: screenColorValue(backgroundIndex),
1662+ HasForegroundRGB: bits&styleForegroundRGBBit != 0,
1663+ ForegroundRGB: fgRGB,
1664+ HasBackgroundRGB: bits&styleBackgroundRGBBit != 0,
1665+ BackgroundRGB: bgRGB,
1666+ HasUnderlineColor: bits&styleUnderlineColorBit != 0,
1667+ UnderlineColor: screenColorValue(underlineIndex),
1668+ HasUnderlineColorRGB: bits&styleUnderlineColorRGBBit != 0,
1669+ UnderlineColorRGB: underlineRGB,
1670+ }
1671+ if style.HasUnderlineColor && style.HasUnderlineColorRGB {
1672+ return renderer.Style{}, false
1673+ }
1674+ style.Attrs = 0
1675+ if bits&styleDimBit != 0 {
1676+ style.Attrs |= renderer.AttrDim
1677+ }
1678+ if bits&styleUnderlineBit != 0 {
1679+ style.Attrs |= renderer.AttrUnderline
1680+ }
1681+ if bits&styleBlinkBit != 0 {
1682+ style.Attrs |= renderer.AttrBlink
1683+ }
1684+ if bits&styleStrikethroughBit != 0 {
1685+ style.Attrs |= renderer.AttrStrikethrough
1686+ }
1687+ if style.HasForegroundRGB {
1688+ if foregroundIndex != math.MaxUint16 {
1689+ return renderer.Style{}, false
1690+ }
1691+ style.Foreground = -1
1692+ } else if (foregroundIndex != math.MaxUint16 && foregroundIndex > math.MaxUint8) || fgRGB != (renderer.RGB{}) {
1693+ return renderer.Style{}, false
1694+ }
1695+ if style.HasBackgroundRGB {
1696+ if backgroundIndex != math.MaxUint16 {
1697+ return renderer.Style{}, false
1698+ }
1699+ style.Background = -1
1700+ } else if (backgroundIndex != math.MaxUint16 && backgroundIndex > math.MaxUint8) || bgRGB != (renderer.RGB{}) {
1701+ return renderer.Style{}, false
1702+ }
1703+ if style.HasUnderlineColorRGB {
1704+ if underlineIndex != math.MaxUint16 {
1705+ return renderer.Style{}, false
1706+ }
1707+ style.UnderlineColor = -1
1708+ } else if style.HasUnderlineColor {
1709+ if underlineIndex == math.MaxUint16 || underlineIndex > math.MaxUint8 || underlineRGB != (renderer.RGB{}) {
1710+ return renderer.Style{}, false
1711+ }
1712+ } else if underlineIndex != math.MaxUint16 || underlineRGB != (renderer.RGB{}) {
1713+ return renderer.Style{}, false
1714+ }
1715+ return style, true
1716+}
1717+
1718+func screenRunCount(cells []renderer.Cell) int {
1719+ if len(cells) == 0 {
1720+ return 0
1721+ }
1722+ n := 1
1723+ previous := canonicalScreenStyle(cells[0].Style)
1724+ for _, cell := range cells[1:] {
1725+ style := canonicalScreenStyle(cell.Style)
1726+ if style != previous {
1727+ n++
1728+ previous = style
1729+ }
1730+ }
1731+ return n
1732+}
1733+
1734+func screenRune(token uint64) (rune, bool) {
1735+ if token == 0 || token > uint64(utf8.MaxRune)+1 {
1736+ return 0, false
1737+ }
1738+ r := rune(token - 1)
1739+ return r, utf8.ValidRune(r)
1740+}
1741+
1742+type screenWriter struct {
1743+ b []byte
1744+ tooLarge bool
1745+}
1746+
1747+func (w *screenWriter) append(p ...byte) {
1748+ if w.tooLarge || len(w.b) > screenPayloadLimit-len(p) {
1749+ w.tooLarge = true
1750+ return
1751+ }
1752+ w.b = append(w.b, p...)
1753+}
1754+func (w *screenWriter) u8(v uint8) { w.append(v) }
1755+func (w *screenWriter) u16(v uint16) { w.append(byte(v>>8), byte(v)) }
1756+func (w *screenWriter) u64(v uint64) {
1757+ var b [8]byte
1758+ binary.BigEndian.PutUint64(b[:], v)
1759+ w.append(b[:]...)
1760+}
1761+func (w *screenWriter) uvarint(v uint64) {
1762+ var b [10]byte
1763+ n := binary.PutUvarint(b[:], v)
1764+ w.append(b[:n]...)
1765+}
1766+
1767+type screenReader struct{ b []byte }
1768+
1769+func (r *screenReader) take(n int) ([]byte, bool) {
1770+ if n < 0 || len(r.b) < n {
1771+ return nil, false
1772+ }
1773+ p := r.b[:n]
1774+ r.b = r.b[n:]
1775+ return p, true
1776+}
1777+func (r *screenReader) u8() (uint8, error) {
1778+ v, ok := r.u8ok()
1779+ if !ok {
1780+ return 0, ErrInvalidScreenUpdate
1781+ }
1782+ return v, nil
1783+}
1784+func (r *screenReader) u8ok() (uint8, bool) {
1785+ p, ok := r.take(1)
1786+ if !ok {
1787+ return 0, false
1788+ }
1789+ return p[0], true
1790+}
1791+func (r *screenReader) u16() (uint16, error) {
1792+ p, ok := r.take(2)
1793+ if !ok {
1794+ return 0, ErrInvalidScreenUpdate
1795+ }
1796+ return binary.BigEndian.Uint16(p), nil
1797+}
1798+func (r *screenReader) u16ok() (uint16, bool) {
1799+ p, ok := r.take(2)
1800+ if !ok {
1801+ return 0, false
1802+ }
1803+ return binary.BigEndian.Uint16(p), true
1804+}
1805+func (r *screenReader) u64() (uint64, error) {
1806+ p, ok := r.take(8)
1807+ if !ok {
1808+ return 0, ErrInvalidScreenUpdate
1809+ }
1810+ return binary.BigEndian.Uint64(p), nil
1811+}
1812+func (r *screenReader) rgb() (renderer.RGB, bool) {
1813+ p, ok := r.take(3)
1814+ if !ok {
1815+ return renderer.RGB{}, false
1816+ }
1817+ return renderer.RGB{R: p[0], G: p[1], B: p[2]}, true
1818+}
1819+func (r *screenReader) uvarint() (uint64, bool) {
1820+ v, n := binary.Uvarint(r.b)
1821+ if n <= 0 {
1822+ return 0, false
1823+ }
1824+ var canonical [binary.MaxVarintLen64]byte
1825+ if binary.PutUvarint(canonical[:], v) != n {
1826+ return 0, false
1827+ }
1828+ r.b = r.b[n:]
1829+ return v, true
1830+}
A internal/ports/wire_screen_test.go +412 -0
@@ -0,0 +1,412 @@
1+package ports
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "encoding/hex"
7+ "errors"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+type screenGoldenFixture struct {
15+ name string
16+ msg ScreenUpdate
17+ hex string
18+}
19+
20+func screenGoldenMessages() []screenGoldenFixture {
21+ defaultCell := func(r rune) renderer.Cell {
22+ return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
23+ }
24+ return []screenGoldenFixture{
25+ {
26+ name: "initial 1x1 snapshot",
27+ msg: ScreenUpdate{
28+ NewStateNum: 1, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 1, Rows: 1},
29+ Cursor: ScreenCursor{Visible: true},
30+ Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell(' ')}}},
31+ },
32+ hex: "000000000000000000000000000000010000000000000000010001000100000000000100000100010000ffffffffffff0000000000000000000000000000000100010000000121",
33+ },
34+ {
35+ name: "explicit zero cursor style",
36+ msg: ScreenUpdate{
37+ NewStateNum: 1, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 1, Rows: 1},
38+ Cursor: ScreenCursor{Visible: true, StyleSet: true},
39+ Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell(' ')}}},
40+ },
41+ hex: "000000000000000000000000000000010000000000000000010001000100000000000300000100010000ffffffffffff0000000000000000000000000000000100010000000121",
42+ },
43+ {
44+ name: "later base-zero snapshot",
45+ msg: ScreenUpdate{
46+ NewStateNum: 9, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 1, Rows: 1},
47+ Cursor: ScreenCursor{Visible: true},
48+ Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell(' ')}}},
49+ },
50+ hex: "000000000000000000000000000000090000000000000000010001000100000000000100000100010000ffffffffffff0000000000000000000000000000000100010000000121",
51+ },
52+ {
53+ name: "cursor-only delta",
54+ msg: ScreenUpdate{
55+ BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Kind: ScreenUpdateDelta,
56+ Size: domain.Size{Cols: 1, Rows: 1}, Cursor: ScreenCursor{Visible: true, StyleSet: true},
57+ },
58+ hex: "00000000000000010000000000000002000000000000000702000100010000000000030000000000",
59+ },
60+ {
61+ name: "upward scroll",
62+ msg: ScreenUpdate{
63+ BaseStateNum: 1, NewStateNum: 2, Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 2, Rows: 2},
64+ Cursor: ScreenCursor{Visible: true}, Scroll: &ScreenScroll{Top: 0, Height: 2, Count: 1},
65+ Spans: []ScreenSpan{{Y: 1, Cells: []renderer.Cell{defaultCell('a'), defaultCell('b')}}},
66+ },
67+ hex: "000000000000000100000000000000020000000000000000020002000200000000000101000100010000000200010000ffffffffffff000000000000000000000001000000020001000000026263",
68+ },
69+ {
70+ name: "unicode and wide continuation",
71+ msg: ScreenUpdate{
72+ BaseStateNum: 1, NewStateNum: 2, Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 3, Rows: 1},
73+ Cursor: ScreenCursor{Visible: true},
74+ Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell('界'), {Continuation: true, Style: renderer.DefaultStyle()}, defaultCell('x')}}},
75+ },
76+ hex: "000000000000000100000000000000020000000000000000020003000100000000000100000100010000ffffffffffff00000000000000000000000000000003000100000003cdea010079",
77+ },
78+ }
79+}
80+
81+func screenGoldenMessage(name string) screenGoldenFixture {
82+ for _, fixture := range screenGoldenMessages() {
83+ if fixture.name == name {
84+ return fixture
85+ }
86+ }
87+ panic("unknown screen golden fixture: " + name)
88+}
89+
90+func TestScreenUpdateStateNumbers(t *testing.T) {
91+ snapshot := screenGoldenMessage("initial 1x1 snapshot").msg
92+ delta := screenGoldenMessage("cursor-only delta").msg
93+ valid := []ScreenUpdate{
94+ snapshot,
95+ func() ScreenUpdate {
96+ m := snapshot
97+ m.NewStateNum = 9
98+ return m
99+ }(),
100+ delta,
101+ }
102+ for _, m := range valid {
103+ wire, err := MarshalScreenUpdate(m)
104+ if err != nil {
105+ t.Fatalf("valid %d state numbers: %v", m.Kind, err)
106+ }
107+ if _, err := UnmarshalScreenUpdate(wire); err != nil {
108+ t.Fatalf("valid %d state numbers decode: %v", m.Kind, err)
109+ }
110+ }
111+
112+ invalid := []struct {
113+ name string
114+ kind ScreenUpdateKind
115+ base uint64
116+ new uint64
117+ }{
118+ {"snapshot nonzero base", ScreenUpdateSnapshot, 1, 1},
119+ {"snapshot zero new", ScreenUpdateSnapshot, 0, 0},
120+ {"delta zero base", ScreenUpdateDelta, 0, 1},
121+ {"delta zero new", ScreenUpdateDelta, 1, 0},
122+ {"delta equal", ScreenUpdateDelta, 1, 1},
123+ {"delta gap", ScreenUpdateDelta, 1, 3},
124+ {"delta overflow", ScreenUpdateDelta, ^uint64(0), 0},
125+ }
126+ for _, tt := range invalid {
127+ t.Run(tt.name, func(t *testing.T) {
128+ m := snapshot
129+ if tt.kind == ScreenUpdateDelta {
130+ m = delta
131+ }
132+ m.BaseStateNum, m.NewStateNum = tt.base, tt.new
133+ if _, err := MarshalScreenUpdate(m); !errors.Is(err, ErrInvalidScreenUpdate) {
134+ t.Fatalf("marshal err = %v", err)
135+ }
136+
137+ validWire, err := MarshalScreenUpdate(map[ScreenUpdateKind]ScreenUpdate{
138+ ScreenUpdateSnapshot: snapshot,
139+ ScreenUpdateDelta: delta,
140+ }[tt.kind])
141+ if err != nil {
142+ t.Fatal(err)
143+ }
144+ binary.BigEndian.PutUint64(validWire[0:8], tt.base)
145+ binary.BigEndian.PutUint64(validWire[8:16], tt.new)
146+ if _, err := UnmarshalScreenUpdate(validWire); !errors.Is(err, ErrInvalidScreenUpdate) {
147+ t.Fatalf("unmarshal err = %v", err)
148+ }
149+ })
150+ }
151+}
152+
153+func TestScreenUpdateGoldens(t *testing.T) {
154+ for _, tt := range screenGoldenMessages() {
155+ t.Run(tt.name, func(t *testing.T) {
156+ want, err := hex.DecodeString(tt.hex)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ got, err := MarshalScreenUpdate(tt.msg)
161+ if err != nil {
162+ t.Fatal(err)
163+ }
164+ if !bytes.Equal(got, want) {
165+ t.Fatalf("wire = %x, want %x", got, want)
166+ }
167+ decoded, err := UnmarshalScreenUpdate(want)
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ assertScreenEqual(t, tt.msg, decoded)
172+ })
173+ }
174+}
175+
176+func assertScreenEqual(t *testing.T, want, got ScreenUpdate) {
177+ t.Helper()
178+ if want.BaseStateNum != got.BaseStateNum || want.NewStateNum != got.NewStateNum || want.EchoAck != got.EchoAck || want.Kind != got.Kind || want.Size != got.Size || want.Cursor != got.Cursor || len(want.Spans) != len(got.Spans) {
179+ t.Fatalf("screen metadata = %+v, want %+v", got, want)
180+ }
181+ if (want.Scroll == nil) != (got.Scroll == nil) || want.Scroll != nil && *want.Scroll != *got.Scroll {
182+ t.Fatalf("scroll = %+v, want %+v", got.Scroll, want.Scroll)
183+ }
184+ for i := range want.Spans {
185+ if want.Spans[i].Y != got.Spans[i].Y || want.Spans[i].X != got.Spans[i].X || len(want.Spans[i].Cells) != len(got.Spans[i].Cells) {
186+ t.Fatalf("span %d = %+v, want %+v", i, got.Spans[i], want.Spans[i])
187+ }
188+ for j := range want.Spans[i].Cells {
189+ if !want.Spans[i].Cells[j].Equal(got.Spans[i].Cells[j]) {
190+ t.Fatalf("cell %d/%d = %+v, want %+v", i, j, got.Spans[i].Cells[j], want.Spans[i].Cells[j])
191+ }
192+ }
193+ }
194+}
195+
196+func TestScreenUpdateStrictTruncationAndTrailing(t *testing.T) {
197+ for _, tt := range screenGoldenMessages() {
198+ data, err := hex.DecodeString(tt.hex)
199+ if err != nil {
200+ t.Fatal(err)
201+ }
202+ for n := 0; n < len(data); n++ {
203+ if _, err := UnmarshalScreenUpdate(data[:n]); !errors.Is(err, ErrInvalidScreenUpdate) {
204+ t.Fatalf("%s prefix %d: err = %v", tt.name, n, err)
205+ }
206+ }
207+ trailing := append(append([]byte(nil), data...), 0)
208+ if _, err := UnmarshalScreenUpdate(trailing); !errors.Is(err, ErrInvalidScreenUpdate) {
209+ t.Fatalf("%s trailing: err = %v", tt.name, err)
210+ }
211+ }
212+}
213+
214+func TestScreenUpdateHostileFields(t *testing.T) {
215+ golden := mustDecodeHex(t, screenGoldenMessage("initial 1x1 snapshot").hex)
216+ tests := []struct {
217+ name string
218+ mut func([]byte)
219+ }{
220+ {"zero columns", func(b []byte) { b[25], b[26] = 0, 0 }},
221+ {"unknown kind", func(b []byte) { b[24] = 3 }},
222+ {"unknown cursor flags", func(b []byte) { b[34] = 4 }},
223+ {"unknown style bits", func(b []byte) { b[40], b[41] = 0x08, 0x00 }},
224+ {"noncanonical foreground RGB", func(b []byte) { b[40], b[41] = 0, 0x80; b[42], b[43] = 0, 0 }},
225+ {"style ID mismatch", func(b []byte) { b[66], b[67] = 0, 1 }},
226+ {"run cell mismatch", func(b []byte) { b[68], b[69] = 0, 2 }},
227+ {"invalid Unicode", func(b []byte) { b[len(b)-1] = 0x81 }},
228+ {"truncated final varint", func(b []byte) { b[len(b)-1] = 0x80 }},
229+ }
230+ for _, tt := range tests {
231+ t.Run(tt.name, func(t *testing.T) {
232+ data := append([]byte(nil), golden...)
233+ tt.mut(data)
234+ if _, err := UnmarshalScreenUpdate(data); !errors.Is(err, ErrInvalidScreenUpdate) {
235+ t.Fatalf("err = %v", err)
236+ }
237+ })
238+ }
239+ for _, tt := range []struct {
240+ name string
241+ token []byte
242+ }{
243+ {"noncanonical varint", []byte{0xa1, 0x00}},
244+ {"overflow varint", append(bytes.Repeat([]byte{0xff}, 10), 0x02)},
245+ } {
246+ t.Run(tt.name, func(t *testing.T) {
247+ data := append(append([]byte(nil), golden[:len(golden)-1]...), tt.token...)
248+ if _, err := UnmarshalScreenUpdate(data); !errors.Is(err, ErrInvalidScreenUpdate) {
249+ t.Fatalf("err = %v", err)
250+ }
251+ })
252+ }
253+}
254+
255+func TestScreenUpdateSpanAndShapeValidation(t *testing.T) {
256+ cell := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
257+ base := ScreenUpdate{
258+ BaseStateNum: 1, NewStateNum: 2,
259+ Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 3, Rows: 2}, Cursor: ScreenCursor{Visible: true},
260+ Spans: []ScreenSpan{{Y: 0, X: 0, Cells: []renderer.Cell{cell}}, {Y: 1, X: 0, Cells: []renderer.Cell{cell}}},
261+ }
262+ if err := ValidateScreenUpdate(base); err != nil {
263+ t.Fatalf("test base is invalid: %v", err)
264+ }
265+ tests := []struct {
266+ name string
267+ edit func(*ScreenUpdate)
268+ }{
269+ {"unordered", func(m *ScreenUpdate) { m.Spans[0].Y = 1; m.Spans[1].Y = 0 }},
270+ {"overlap", func(m *ScreenUpdate) { m.Spans[1].Y = 0; m.Spans[1].X = 0 }},
271+ {"out of bounds", func(m *ScreenUpdate) { m.Spans[1].X = 3 }},
272+ {"zero scroll", func(m *ScreenUpdate) { m.Scroll = &ScreenScroll{Top: 0, Height: 0, Count: 1} }},
273+ {"scroll count reaches height", func(m *ScreenUpdate) { m.Scroll = &ScreenScroll{Top: 0, Height: 1, Count: 1} }},
274+ }
275+ for _, tt := range tests {
276+ t.Run(tt.name, func(t *testing.T) {
277+ m := base
278+ m.Spans = append([]ScreenSpan(nil), base.Spans...)
279+ tt.edit(&m)
280+ if _, err := MarshalScreenUpdate(m); !errors.Is(err, ErrInvalidScreenUpdate) {
281+ t.Fatalf("err = %v", err)
282+ }
283+ })
284+ }
285+ tooWide := base
286+ tooWide.Size = domain.Size{Cols: 1, Rows: 1}
287+ tooWide.Spans = []ScreenSpan{{Cells: []renderer.Cell{cell, cell}}}
288+ if _, err := MarshalScreenUpdate(tooWide); !errors.Is(err, ErrInvalidScreenUpdate) {
289+ t.Fatalf("out-of-bounds span err = %v", err)
290+ }
291+ if _, err := MarshalScreenUpdate(ScreenUpdate{BaseStateNum: 1, NewStateNum: 2, Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 1, Rows: 1}, Cursor: ScreenCursor{}, Spans: []ScreenSpan{{Cells: []renderer.Cell{{Rune: rune(0xd800)}}}}}); !errors.Is(err, ErrInvalidScreenUpdate) {
292+ t.Fatalf("invalid rune err = %v", err)
293+ }
294+ if _, err := MarshalScreenUpdate(ScreenUpdate{NewStateNum: 1, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 2, Rows: 2}, Cursor: ScreenCursor{}, Spans: []ScreenSpan{{Cells: []renderer.Cell{cell, cell}}}}); !errors.Is(err, ErrInvalidScreenUpdate) {
295+ t.Fatalf("incomplete snapshot err = %v", err)
296+ }
297+ if _, err := MarshalScreenUpdate(ScreenUpdate{BaseStateNum: 1, NewStateNum: 2, Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 1, Rows: 1}, Cursor: ScreenCursor{StyleSet: true, Style: 7}, Spans: []ScreenSpan{{Cells: []renderer.Cell{cell}}}}); !errors.Is(err, ErrInvalidScreenUpdate) {
298+ t.Fatalf("cursor style err = %v", err)
299+ }
300+}
301+
302+func TestScreenUpdateOwnership(t *testing.T) {
303+ golden := mustDecodeHex(t, screenGoldenMessage("initial 1x1 snapshot").hex)
304+ decoded, err := UnmarshalScreenUpdate(golden)
305+ if err != nil {
306+ t.Fatal(err)
307+ }
308+ golden[0] ^= 0xff
309+ golden[len(golden)-1] ^= 0xff
310+ want, err := MarshalScreenUpdate(decoded)
311+ if err != nil {
312+ t.Fatal(err)
313+ }
314+ if !bytes.Equal(want, mustDecodeHex(t, screenGoldenMessage("initial 1x1 snapshot").hex)) {
315+ t.Fatal("decoded screen retained transport payload")
316+ }
317+}
318+
319+func TestScreenUpdateLimits(t *testing.T) {
320+ if _, err := UnmarshalScreenUpdate(make([]byte, MaxFrameLen)); !errors.Is(err, ErrScreenUpdateTooLarge) {
321+ t.Fatalf("oversize err = %v", err)
322+ }
323+ tooMany := screenGoldenMessage("initial 1x1 snapshot").msg
324+ tooMany.Spans = make([]ScreenSpan, MaxScreenSpans+1)
325+ if _, err := MarshalScreenUpdate(tooMany); !errors.Is(err, ErrInvalidScreenUpdate) {
326+ t.Fatalf("span limit err = %v", err)
327+ }
328+}
329+
330+func TestScreenUpdateScreenAreaLimit(t *testing.T) {
331+ boundary := ScreenUpdate{
332+ BaseStateNum: 1,
333+ NewStateNum: 2,
334+ Kind: ScreenUpdateDelta,
335+ Size: domain.Size{Cols: 512, Rows: 512},
336+ Cursor: ScreenCursor{Visible: true},
337+ }
338+ wire, err := MarshalScreenUpdate(boundary)
339+ if err != nil {
340+ t.Fatalf("boundary marshal: %v", err)
341+ }
342+ if _, err := UnmarshalScreenUpdate(wire); err != nil {
343+ t.Fatalf("boundary unmarshal: %v", err)
344+ }
345+
346+ over := boundary
347+ over.Size = domain.Size{Cols: 513, Rows: 512}
348+ if _, err := MarshalScreenUpdate(over); !errors.Is(err, ErrInvalidScreenUpdate) {
349+ t.Fatalf("over-cap marshal err = %v", err)
350+ }
351+
352+ // Include a span descriptor and enough cell tokens that an unchecked
353+ // decoder would reach its per-span cell allocation.
354+ hostile := make([]byte, screenHeaderLen+8+4+513)
355+ hostile[24] = byte(ScreenUpdateDelta)
356+ binary.BigEndian.PutUint16(hostile[25:27], 513)
357+ binary.BigEndian.PutUint16(hostile[27:29], 512)
358+ binary.BigEndian.PutUint16(hostile[38:40], 1)
359+ binary.BigEndian.PutUint16(hostile[40:42], 0) // y
360+ binary.BigEndian.PutUint16(hostile[42:44], 0) // x
361+ binary.BigEndian.PutUint16(hostile[44:46], 513)
362+ binary.BigEndian.PutUint16(hostile[46:48], 1)
363+ binary.BigEndian.PutUint16(hostile[48:50], 0) // style ID
364+ binary.BigEndian.PutUint16(hostile[50:52], 513)
365+ if _, err := UnmarshalScreenUpdate(hostile); !errors.Is(err, ErrInvalidScreenUpdate) {
366+ t.Fatalf("over-cap hostile header err = %v", err)
367+ }
368+ if allocs := testing.AllocsPerRun(100, func() {
369+ if _, err := UnmarshalScreenUpdate(hostile); !errors.Is(err, ErrInvalidScreenUpdate) {
370+ panic(err)
371+ }
372+ }); allocs != 0 {
373+ t.Fatalf("over-cap hostile header allocations = %v, want 0", allocs)
374+ }
375+}
376+
377+func mustDecodeHex(t *testing.T, s string) []byte {
378+ t.Helper()
379+ b, err := hex.DecodeString(s)
380+ if err != nil {
381+ t.Fatal(err)
382+ }
383+ return b
384+}
385+
386+func FuzzScreenUpdate(f *testing.F) {
387+ for _, tt := range screenGoldenMessages() {
388+ b, err := hex.DecodeString(tt.hex)
389+ if err != nil {
390+ f.Fatalf("seed %s: %v", tt.name, err)
391+ }
392+ f.Add(b)
393+ }
394+ f.Fuzz(func(t *testing.T, data []byte) {
395+ decoded, err := UnmarshalScreenUpdate(data)
396+ if err != nil {
397+ return
398+ }
399+ encoded, err := MarshalScreenUpdate(decoded)
400+ if err != nil {
401+ t.Fatal(err)
402+ }
403+ if !bytes.Equal(encoded, data) {
404+ t.Fatalf("re-encoded accepted input = %x, want %x", encoded, data)
405+ }
406+ roundtrip, err := UnmarshalScreenUpdate(encoded)
407+ if err != nil {
408+ t.Fatal(err)
409+ }
410+ assertScreenEqual(t, decoded, roundtrip)
411+ })
412+}
@@ -0,0 +1,412 @@
1+package ports
2+
3+import (
4+ "bytes"
5+ "encoding/binary"
6+ "encoding/hex"
7+ "errors"
8+ "testing"
9+
10+ "github.com/bnema/vev/internal/domain"
11+ "github.com/bnema/vev/pkg/renderer"
12+)
13+
14+type screenGoldenFixture struct {
15+ name string
16+ msg ScreenUpdate
17+ hex string
18+}
19+
20+func screenGoldenMessages() []screenGoldenFixture {
21+ defaultCell := func(r rune) renderer.Cell {
22+ return renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
23+ }
24+ return []screenGoldenFixture{
25+ {
26+ name: "initial 1x1 snapshot",
27+ msg: ScreenUpdate{
28+ NewStateNum: 1, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 1, Rows: 1},
29+ Cursor: ScreenCursor{Visible: true},
30+ Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell(' ')}}},
31+ },
32+ hex: "000000000000000000000000000000010000000000000000010001000100000000000100000100010000ffffffffffff0000000000000000000000000000000100010000000121",
33+ },
34+ {
35+ name: "explicit zero cursor style",
36+ msg: ScreenUpdate{
37+ NewStateNum: 1, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 1, Rows: 1},
38+ Cursor: ScreenCursor{Visible: true, StyleSet: true},
39+ Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell(' ')}}},
40+ },
41+ hex: "000000000000000000000000000000010000000000000000010001000100000000000300000100010000ffffffffffff0000000000000000000000000000000100010000000121",
42+ },
43+ {
44+ name: "later base-zero snapshot",
45+ msg: ScreenUpdate{
46+ NewStateNum: 9, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 1, Rows: 1},
47+ Cursor: ScreenCursor{Visible: true},
48+ Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell(' ')}}},
49+ },
50+ hex: "000000000000000000000000000000090000000000000000010001000100000000000100000100010000ffffffffffff0000000000000000000000000000000100010000000121",
51+ },
52+ {
53+ name: "cursor-only delta",
54+ msg: ScreenUpdate{
55+ BaseStateNum: 1, NewStateNum: 2, EchoAck: 7, Kind: ScreenUpdateDelta,
56+ Size: domain.Size{Cols: 1, Rows: 1}, Cursor: ScreenCursor{Visible: true, StyleSet: true},
57+ },
58+ hex: "00000000000000010000000000000002000000000000000702000100010000000000030000000000",
59+ },
60+ {
61+ name: "upward scroll",
62+ msg: ScreenUpdate{
63+ BaseStateNum: 1, NewStateNum: 2, Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 2, Rows: 2},
64+ Cursor: ScreenCursor{Visible: true}, Scroll: &ScreenScroll{Top: 0, Height: 2, Count: 1},
65+ Spans: []ScreenSpan{{Y: 1, Cells: []renderer.Cell{defaultCell('a'), defaultCell('b')}}},
66+ },
67+ hex: "000000000000000100000000000000020000000000000000020002000200000000000101000100010000000200010000ffffffffffff000000000000000000000001000000020001000000026263",
68+ },
69+ {
70+ name: "unicode and wide continuation",
71+ msg: ScreenUpdate{
72+ BaseStateNum: 1, NewStateNum: 2, Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 3, Rows: 1},
73+ Cursor: ScreenCursor{Visible: true},
74+ Spans: []ScreenSpan{{Cells: []renderer.Cell{defaultCell('界'), {Continuation: true, Style: renderer.DefaultStyle()}, defaultCell('x')}}},
75+ },
76+ hex: "000000000000000100000000000000020000000000000000020003000100000000000100000100010000ffffffffffff00000000000000000000000000000003000100000003cdea010079",
77+ },
78+ }
79+}
80+
81+func screenGoldenMessage(name string) screenGoldenFixture {
82+ for _, fixture := range screenGoldenMessages() {
83+ if fixture.name == name {
84+ return fixture
85+ }
86+ }
87+ panic("unknown screen golden fixture: " + name)
88+}
89+
90+func TestScreenUpdateStateNumbers(t *testing.T) {
91+ snapshot := screenGoldenMessage("initial 1x1 snapshot").msg
92+ delta := screenGoldenMessage("cursor-only delta").msg
93+ valid := []ScreenUpdate{
94+ snapshot,
95+ func() ScreenUpdate {
96+ m := snapshot
97+ m.NewStateNum = 9
98+ return m
99+ }(),
100+ delta,
101+ }
102+ for _, m := range valid {
103+ wire, err := MarshalScreenUpdate(m)
104+ if err != nil {
105+ t.Fatalf("valid %d state numbers: %v", m.Kind, err)
106+ }
107+ if _, err := UnmarshalScreenUpdate(wire); err != nil {
108+ t.Fatalf("valid %d state numbers decode: %v", m.Kind, err)
109+ }
110+ }
111+
112+ invalid := []struct {
113+ name string
114+ kind ScreenUpdateKind
115+ base uint64
116+ new uint64
117+ }{
118+ {"snapshot nonzero base", ScreenUpdateSnapshot, 1, 1},
119+ {"snapshot zero new", ScreenUpdateSnapshot, 0, 0},
120+ {"delta zero base", ScreenUpdateDelta, 0, 1},
121+ {"delta zero new", ScreenUpdateDelta, 1, 0},
122+ {"delta equal", ScreenUpdateDelta, 1, 1},
123+ {"delta gap", ScreenUpdateDelta, 1, 3},
124+ {"delta overflow", ScreenUpdateDelta, ^uint64(0), 0},
125+ }
126+ for _, tt := range invalid {
127+ t.Run(tt.name, func(t *testing.T) {
128+ m := snapshot
129+ if tt.kind == ScreenUpdateDelta {
130+ m = delta
131+ }
132+ m.BaseStateNum, m.NewStateNum = tt.base, tt.new
133+ if _, err := MarshalScreenUpdate(m); !errors.Is(err, ErrInvalidScreenUpdate) {
134+ t.Fatalf("marshal err = %v", err)
135+ }
136+
137+ validWire, err := MarshalScreenUpdate(map[ScreenUpdateKind]ScreenUpdate{
138+ ScreenUpdateSnapshot: snapshot,
139+ ScreenUpdateDelta: delta,
140+ }[tt.kind])
141+ if err != nil {
142+ t.Fatal(err)
143+ }
144+ binary.BigEndian.PutUint64(validWire[0:8], tt.base)
145+ binary.BigEndian.PutUint64(validWire[8:16], tt.new)
146+ if _, err := UnmarshalScreenUpdate(validWire); !errors.Is(err, ErrInvalidScreenUpdate) {
147+ t.Fatalf("unmarshal err = %v", err)
148+ }
149+ })
150+ }
151+}
152+
153+func TestScreenUpdateGoldens(t *testing.T) {
154+ for _, tt := range screenGoldenMessages() {
155+ t.Run(tt.name, func(t *testing.T) {
156+ want, err := hex.DecodeString(tt.hex)
157+ if err != nil {
158+ t.Fatal(err)
159+ }
160+ got, err := MarshalScreenUpdate(tt.msg)
161+ if err != nil {
162+ t.Fatal(err)
163+ }
164+ if !bytes.Equal(got, want) {
165+ t.Fatalf("wire = %x, want %x", got, want)
166+ }
167+ decoded, err := UnmarshalScreenUpdate(want)
168+ if err != nil {
169+ t.Fatal(err)
170+ }
171+ assertScreenEqual(t, tt.msg, decoded)
172+ })
173+ }
174+}
175+
176+func assertScreenEqual(t *testing.T, want, got ScreenUpdate) {
177+ t.Helper()
178+ if want.BaseStateNum != got.BaseStateNum || want.NewStateNum != got.NewStateNum || want.EchoAck != got.EchoAck || want.Kind != got.Kind || want.Size != got.Size || want.Cursor != got.Cursor || len(want.Spans) != len(got.Spans) {
179+ t.Fatalf("screen metadata = %+v, want %+v", got, want)
180+ }
181+ if (want.Scroll == nil) != (got.Scroll == nil) || want.Scroll != nil && *want.Scroll != *got.Scroll {
182+ t.Fatalf("scroll = %+v, want %+v", got.Scroll, want.Scroll)
183+ }
184+ for i := range want.Spans {
185+ if want.Spans[i].Y != got.Spans[i].Y || want.Spans[i].X != got.Spans[i].X || len(want.Spans[i].Cells) != len(got.Spans[i].Cells) {
186+ t.Fatalf("span %d = %+v, want %+v", i, got.Spans[i], want.Spans[i])
187+ }
188+ for j := range want.Spans[i].Cells {
189+ if !want.Spans[i].Cells[j].Equal(got.Spans[i].Cells[j]) {
190+ t.Fatalf("cell %d/%d = %+v, want %+v", i, j, got.Spans[i].Cells[j], want.Spans[i].Cells[j])
191+ }
192+ }
193+ }
194+}
195+
196+func TestScreenUpdateStrictTruncationAndTrailing(t *testing.T) {
197+ for _, tt := range screenGoldenMessages() {
198+ data, err := hex.DecodeString(tt.hex)
199+ if err != nil {
200+ t.Fatal(err)
201+ }
202+ for n := 0; n < len(data); n++ {
203+ if _, err := UnmarshalScreenUpdate(data[:n]); !errors.Is(err, ErrInvalidScreenUpdate) {
204+ t.Fatalf("%s prefix %d: err = %v", tt.name, n, err)
205+ }
206+ }
207+ trailing := append(append([]byte(nil), data...), 0)
208+ if _, err := UnmarshalScreenUpdate(trailing); !errors.Is(err, ErrInvalidScreenUpdate) {
209+ t.Fatalf("%s trailing: err = %v", tt.name, err)
210+ }
211+ }
212+}
213+
214+func TestScreenUpdateHostileFields(t *testing.T) {
215+ golden := mustDecodeHex(t, screenGoldenMessage("initial 1x1 snapshot").hex)
216+ tests := []struct {
217+ name string
218+ mut func([]byte)
219+ }{
220+ {"zero columns", func(b []byte) { b[25], b[26] = 0, 0 }},
221+ {"unknown kind", func(b []byte) { b[24] = 3 }},
222+ {"unknown cursor flags", func(b []byte) { b[34] = 4 }},
223+ {"unknown style bits", func(b []byte) { b[40], b[41] = 0x08, 0x00 }},
224+ {"noncanonical foreground RGB", func(b []byte) { b[40], b[41] = 0, 0x80; b[42], b[43] = 0, 0 }},
225+ {"style ID mismatch", func(b []byte) { b[66], b[67] = 0, 1 }},
226+ {"run cell mismatch", func(b []byte) { b[68], b[69] = 0, 2 }},
227+ {"invalid Unicode", func(b []byte) { b[len(b)-1] = 0x81 }},
228+ {"truncated final varint", func(b []byte) { b[len(b)-1] = 0x80 }},
229+ }
230+ for _, tt := range tests {
231+ t.Run(tt.name, func(t *testing.T) {
232+ data := append([]byte(nil), golden...)
233+ tt.mut(data)
234+ if _, err := UnmarshalScreenUpdate(data); !errors.Is(err, ErrInvalidScreenUpdate) {
235+ t.Fatalf("err = %v", err)
236+ }
237+ })
238+ }
239+ for _, tt := range []struct {
240+ name string
241+ token []byte
242+ }{
243+ {"noncanonical varint", []byte{0xa1, 0x00}},
244+ {"overflow varint", append(bytes.Repeat([]byte{0xff}, 10), 0x02)},
245+ } {
246+ t.Run(tt.name, func(t *testing.T) {
247+ data := append(append([]byte(nil), golden[:len(golden)-1]...), tt.token...)
248+ if _, err := UnmarshalScreenUpdate(data); !errors.Is(err, ErrInvalidScreenUpdate) {
249+ t.Fatalf("err = %v", err)
250+ }
251+ })
252+ }
253+}
254+
255+func TestScreenUpdateSpanAndShapeValidation(t *testing.T) {
256+ cell := renderer.Cell{Rune: 'x', Style: renderer.DefaultStyle()}
257+ base := ScreenUpdate{
258+ BaseStateNum: 1, NewStateNum: 2,
259+ Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 3, Rows: 2}, Cursor: ScreenCursor{Visible: true},
260+ Spans: []ScreenSpan{{Y: 0, X: 0, Cells: []renderer.Cell{cell}}, {Y: 1, X: 0, Cells: []renderer.Cell{cell}}},
261+ }
262+ if err := ValidateScreenUpdate(base); err != nil {
263+ t.Fatalf("test base is invalid: %v", err)
264+ }
265+ tests := []struct {
266+ name string
267+ edit func(*ScreenUpdate)
268+ }{
269+ {"unordered", func(m *ScreenUpdate) { m.Spans[0].Y = 1; m.Spans[1].Y = 0 }},
270+ {"overlap", func(m *ScreenUpdate) { m.Spans[1].Y = 0; m.Spans[1].X = 0 }},
271+ {"out of bounds", func(m *ScreenUpdate) { m.Spans[1].X = 3 }},
272+ {"zero scroll", func(m *ScreenUpdate) { m.Scroll = &ScreenScroll{Top: 0, Height: 0, Count: 1} }},
273+ {"scroll count reaches height", func(m *ScreenUpdate) { m.Scroll = &ScreenScroll{Top: 0, Height: 1, Count: 1} }},
274+ }
275+ for _, tt := range tests {
276+ t.Run(tt.name, func(t *testing.T) {
277+ m := base
278+ m.Spans = append([]ScreenSpan(nil), base.Spans...)
279+ tt.edit(&m)
280+ if _, err := MarshalScreenUpdate(m); !errors.Is(err, ErrInvalidScreenUpdate) {
281+ t.Fatalf("err = %v", err)
282+ }
283+ })
284+ }
285+ tooWide := base
286+ tooWide.Size = domain.Size{Cols: 1, Rows: 1}
287+ tooWide.Spans = []ScreenSpan{{Cells: []renderer.Cell{cell, cell}}}
288+ if _, err := MarshalScreenUpdate(tooWide); !errors.Is(err, ErrInvalidScreenUpdate) {
289+ t.Fatalf("out-of-bounds span err = %v", err)
290+ }
291+ if _, err := MarshalScreenUpdate(ScreenUpdate{BaseStateNum: 1, NewStateNum: 2, Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 1, Rows: 1}, Cursor: ScreenCursor{}, Spans: []ScreenSpan{{Cells: []renderer.Cell{{Rune: rune(0xd800)}}}}}); !errors.Is(err, ErrInvalidScreenUpdate) {
292+ t.Fatalf("invalid rune err = %v", err)
293+ }
294+ if _, err := MarshalScreenUpdate(ScreenUpdate{NewStateNum: 1, Kind: ScreenUpdateSnapshot, Size: domain.Size{Cols: 2, Rows: 2}, Cursor: ScreenCursor{}, Spans: []ScreenSpan{{Cells: []renderer.Cell{cell, cell}}}}); !errors.Is(err, ErrInvalidScreenUpdate) {
295+ t.Fatalf("incomplete snapshot err = %v", err)
296+ }
297+ if _, err := MarshalScreenUpdate(ScreenUpdate{BaseStateNum: 1, NewStateNum: 2, Kind: ScreenUpdateDelta, Size: domain.Size{Cols: 1, Rows: 1}, Cursor: ScreenCursor{StyleSet: true, Style: 7}, Spans: []ScreenSpan{{Cells: []renderer.Cell{cell}}}}); !errors.Is(err, ErrInvalidScreenUpdate) {
298+ t.Fatalf("cursor style err = %v", err)
299+ }
300+}
301+
302+func TestScreenUpdateOwnership(t *testing.T) {
303+ golden := mustDecodeHex(t, screenGoldenMessage("initial 1x1 snapshot").hex)
304+ decoded, err := UnmarshalScreenUpdate(golden)
305+ if err != nil {
306+ t.Fatal(err)
307+ }
308+ golden[0] ^= 0xff
309+ golden[len(golden)-1] ^= 0xff
310+ want, err := MarshalScreenUpdate(decoded)
311+ if err != nil {
312+ t.Fatal(err)
313+ }
314+ if !bytes.Equal(want, mustDecodeHex(t, screenGoldenMessage("initial 1x1 snapshot").hex)) {
315+ t.Fatal("decoded screen retained transport payload")
316+ }
317+}
318+
319+func TestScreenUpdateLimits(t *testing.T) {
320+ if _, err := UnmarshalScreenUpdate(make([]byte, MaxFrameLen)); !errors.Is(err, ErrScreenUpdateTooLarge) {
321+ t.Fatalf("oversize err = %v", err)
322+ }
323+ tooMany := screenGoldenMessage("initial 1x1 snapshot").msg
324+ tooMany.Spans = make([]ScreenSpan, MaxScreenSpans+1)
325+ if _, err := MarshalScreenUpdate(tooMany); !errors.Is(err, ErrInvalidScreenUpdate) {
326+ t.Fatalf("span limit err = %v", err)
327+ }
328+}
329+
330+func TestScreenUpdateScreenAreaLimit(t *testing.T) {
331+ boundary := ScreenUpdate{
332+ BaseStateNum: 1,
333+ NewStateNum: 2,
334+ Kind: ScreenUpdateDelta,
335+ Size: domain.Size{Cols: 512, Rows: 512},
336+ Cursor: ScreenCursor{Visible: true},
337+ }
338+ wire, err := MarshalScreenUpdate(boundary)
339+ if err != nil {
340+ t.Fatalf("boundary marshal: %v", err)
341+ }
342+ if _, err := UnmarshalScreenUpdate(wire); err != nil {
343+ t.Fatalf("boundary unmarshal: %v", err)
344+ }
345+
346+ over := boundary
347+ over.Size = domain.Size{Cols: 513, Rows: 512}
348+ if _, err := MarshalScreenUpdate(over); !errors.Is(err, ErrInvalidScreenUpdate) {
349+ t.Fatalf("over-cap marshal err = %v", err)
350+ }
351+
352+ // Include a span descriptor and enough cell tokens that an unchecked
353+ // decoder would reach its per-span cell allocation.
354+ hostile := make([]byte, screenHeaderLen+8+4+513)
355+ hostile[24] = byte(ScreenUpdateDelta)
356+ binary.BigEndian.PutUint16(hostile[25:27], 513)
357+ binary.BigEndian.PutUint16(hostile[27:29], 512)
358+ binary.BigEndian.PutUint16(hostile[38:40], 1)
359+ binary.BigEndian.PutUint16(hostile[40:42], 0) // y
360+ binary.BigEndian.PutUint16(hostile[42:44], 0) // x
361+ binary.BigEndian.PutUint16(hostile[44:46], 513)
362+ binary.BigEndian.PutUint16(hostile[46:48], 1)
363+ binary.BigEndian.PutUint16(hostile[48:50], 0) // style ID
364+ binary.BigEndian.PutUint16(hostile[50:52], 513)
365+ if _, err := UnmarshalScreenUpdate(hostile); !errors.Is(err, ErrInvalidScreenUpdate) {
366+ t.Fatalf("over-cap hostile header err = %v", err)
367+ }
368+ if allocs := testing.AllocsPerRun(100, func() {
369+ if _, err := UnmarshalScreenUpdate(hostile); !errors.Is(err, ErrInvalidScreenUpdate) {
370+ panic(err)
371+ }
372+ }); allocs != 0 {
373+ t.Fatalf("over-cap hostile header allocations = %v, want 0", allocs)
374+ }
375+}
376+
377+func mustDecodeHex(t *testing.T, s string) []byte {
378+ t.Helper()
379+ b, err := hex.DecodeString(s)
380+ if err != nil {
381+ t.Fatal(err)
382+ }
383+ return b
384+}
385+
386+func FuzzScreenUpdate(f *testing.F) {
387+ for _, tt := range screenGoldenMessages() {
388+ b, err := hex.DecodeString(tt.hex)
389+ if err != nil {
390+ f.Fatalf("seed %s: %v", tt.name, err)
391+ }
392+ f.Add(b)
393+ }
394+ f.Fuzz(func(t *testing.T, data []byte) {
395+ decoded, err := UnmarshalScreenUpdate(data)
396+ if err != nil {
397+ return
398+ }
399+ encoded, err := MarshalScreenUpdate(decoded)
400+ if err != nil {
401+ t.Fatal(err)
402+ }
403+ if !bytes.Equal(encoded, data) {
404+ t.Fatalf("re-encoded accepted input = %x, want %x", encoded, data)
405+ }
406+ roundtrip, err := UnmarshalScreenUpdate(encoded)
407+ if err != nil {
408+ t.Fatal(err)
409+ }
410+ assertScreenEqual(t, decoded, roundtrip)
411+ })
412+}
M internal/ports/wire_test.go +7 -7
@@ -122,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
122122 Env: []string{"A=B", "XY=123"},
123123 })
124124 want := []byte{
125- 0x00, 0x14, 0x00, 0x00, // version, intent, proxied
125+ 0x00, 0x15, 0x00, 0x00, // version, intent, proxied
126126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
127127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
128128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -336,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
336336 }
337337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
338338 require.Equal(t, want, MarshalTheme(cleared))
339- require.Equal(t, uint16(20), ProtocolVersion)
339+ require.Equal(t, uint16(21), ProtocolVersion)
340340 }
341341
342342 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -461,7 +461,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
461461 name: "minimal",
462462 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
463463 want: []byte{
464- 0x00, 0x14,
464+ 0x00, 0x15,
465465 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
466466 0x00, // attached
467467 0x00, // self
@@ -484,7 +484,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
484484 JSON: true,
485485 },
486486 want: []byte{
487- 0x00, 0x14,
487+ 0x00, 0x15,
488488 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
489489 0x00, // attached
490490 0x01, // self
@@ -914,7 +914,7 @@ func TestProxiedHelloGoldenAndStrict(t *testing.T) {
914914 MaxOutputInFlight: 8,
915915 }
916916 want := []byte{
917- 0x00, 0x14, 0x02, 0x01,
917+ 0x00, 0x15, 0x02, 0x01,
918918 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
919919 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
920920 0x00, 0x06, 'r', 'e', 'm', 'o', 't', 'e',
@@ -947,7 +947,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
947947 name: "attached",
948948 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
949949 want: []byte{
950- 0x00, 0x14, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
950+ 0x00, 0x15, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
951951 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
952952 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
953953 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -957,7 +957,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
957957 name: "control",
958958 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
959959 want: []byte{
960- 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
960+ 0x00, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
961961 0x00, 0x02, 'l', 's', 0x00, 0x00,
962962 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
963963 },
@@ -122,7 +122,7 @@ func TestHelloEnvironmentCodec(t *testing.T) {
122 Env: []string{"A=B", "XY=123"},122 Env: []string{"A=B", "XY=123"},
123 })123 })
124 want := []byte{124 want := []byte{
125- 0x00, 0x14, 0x00, 0x00, // version, intent, proxied125+ 0x00, 0x15, 0x00, 0x00, // version, intent, proxied
126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,126 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID127 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID
128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token128 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token
@@ -336,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T)
336 }336 }
337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)337 want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...)
338 require.Equal(t, want, MarshalTheme(cleared))338 require.Equal(t, want, MarshalTheme(cleared))
339- require.Equal(t, uint16(20), ProtocolVersion)339+ require.Equal(t, uint16(21), ProtocolVersion)
340 }340 }
341 341
342 func TestResizeGoldenAndRoundTrip(t *testing.T) {342 func TestResizeGoldenAndRoundTrip(t *testing.T) {
@@ -461,7 +461,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
461 name: "minimal",461 name: "minimal",
462 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},462 msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"},
463 want: []byte{463 want: []byte{
464- 0x00, 0x14,464+ 0x00, 0x15,
465 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID465 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
466 0x00, // attached466 0x00, // attached
467 0x00, // self467 0x00, // self
@@ -484,7 +484,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) {
484 JSON: true,484 JSON: true,
485 },485 },
486 want: []byte{486 want: []byte{
487- 0x00, 0x14,487+ 0x00, 0x15,
488 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID488 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID
489 0x00, // attached489 0x00, // attached
490 0x01, // self490 0x01, // self
@@ -914,7 +914,7 @@ func TestProxiedHelloGoldenAndStrict(t *testing.T) {
914 MaxOutputInFlight: 8,914 MaxOutputInFlight: 8,
915 }915 }
916 want := []byte{916 want := []byte{
917- 0x00, 0x14, 0x02, 0x01,917+ 0x00, 0x15, 0x02, 0x01,
918 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,918 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10,
919 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,919 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08,
920 0x00, 0x06, 'r', 'e', 'm', 'o', 't', 'e',920 0x00, 0x06, 'r', 'e', 'm', 'o', 't', 'e',
@@ -947,7 +947,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
947 name: "attached",947 name: "attached",
948 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},948 msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}},
949 want: []byte{949 want: []byte{
950- 0x00, 0x14, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,950+ 0x00, 0x15, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x01, 0x01,
951 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',951 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't',
952 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',952 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l',
953 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,953 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
@@ -957,7 +957,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) {
957 name: "control",957 name: "control",
958 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},958 msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"},
959 want: []byte{959 want: []byte{
960- 0x00, 0x14, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,960+ 0x00, 0x15, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
961 0x00, 0x02, 'l', 's', 0x00, 0x00,961 0x00, 0x02, 'l', 's', 0x00, 0x00,
962 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,962 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
963 },963 },
A internal/usecase/daemon/proxy_screen_state.go +274 -0
@@ -0,0 +1,274 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "math"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+const maxProxyScreenDamage = 1024
13+
14+type proxyScreenDamageCapture struct {
15+ Damage []renderer.Damage
16+ Generation uint64
17+}
18+
19+// proxyScreenState is the replayable semantic screen owned by a proxy link.
20+// frame and scratch are independent frames and equal whenever Apply returns.
21+// A delta is applied to scratch, the frames are swapped, and the same delta is
22+// applied to the old frame. This avoids copying a complete frame on deltas.
23+type proxyScreenState struct {
24+ frame renderer.Frame
25+ scratch renderer.Frame
26+ cursorOut ports.ScreenCursor
27+
28+ damage []renderer.Damage
29+ generation uint64
30+ damageFullRedrawSticky bool
31+ stateNum uint64
32+}
33+
34+func newProxyScreenState(size domain.Size) *proxyScreenState {
35+ if !validProxyScreenSize(size) {
36+ return &proxyScreenState{}
37+ }
38+ return &proxyScreenState{
39+ frame: renderer.NewFrame(size.Cols, size.Rows),
40+ scratch: renderer.NewFrame(size.Cols, size.Rows),
41+ cursorOut: ports.ScreenCursor{Visible: true},
42+ damage: []renderer.Damage{renderer.FullRedraw()},
43+ generation: 1,
44+ }
45+}
46+
47+// Apply validates the complete semantic update before mutating either frame.
48+func (s *proxyScreenState) Apply(update ports.ScreenUpdate) error {
49+ if s == nil {
50+ return fmt.Errorf("proxy screen state is nil")
51+ }
52+ if err := s.validateUpdate(update); err != nil {
53+ return err
54+ }
55+
56+ if update.Kind == ports.ScreenUpdateSnapshot {
57+ next := renderer.NewFrame(update.Size.Cols, update.Size.Rows)
58+ nextScratch := renderer.NewFrame(update.Size.Cols, update.Size.Rows)
59+ applyScreenUpdate(&next, update)
60+ applyScreenUpdate(&nextScratch, update)
61+ s.frame, s.scratch = next, nextScratch
62+ } else {
63+ applyScreenUpdate(&s.scratch, update)
64+ s.frame, s.scratch = s.scratch, s.frame
65+ applyScreenUpdate(&s.scratch, update)
66+ }
67+ s.cursorOut = update.Cursor
68+ if update.NewStateNum != 0 {
69+ s.stateNum = update.NewStateNum
70+ }
71+ s.recordUpdate(update)
72+ return nil
73+}
74+
75+func (s *proxyScreenState) validateUpdate(update ports.ScreenUpdate) error {
76+ if err := ports.ValidateScreenUpdate(update); err != nil {
77+ return fmt.Errorf("proxy screen: %w", err)
78+ }
79+ if update.Kind == ports.ScreenUpdateDelta {
80+ if s.frame.Width != update.Size.Cols || s.frame.Height != update.Size.Rows {
81+ return fmt.Errorf("proxy screen: delta size %dx%d does not match %dx%d", update.Size.Cols, update.Size.Rows, s.frame.Width, s.frame.Height)
82+ }
83+ }
84+ return s.validateStateNumbers(update)
85+}
86+
87+func (s *proxyScreenState) validateStateNumbers(update ports.ScreenUpdate) error {
88+ if update.Kind == ports.ScreenUpdateDelta && (s.stateNum == 0 || update.BaseStateNum != s.stateNum) {
89+ return fmt.Errorf("proxy screen: delta base state %d does not match %d", update.BaseStateNum, s.stateNum)
90+ }
91+ return nil
92+}
93+
94+func applyScreenUpdate(frame *renderer.Frame, update ports.ScreenUpdate) {
95+ if update.Scroll != nil {
96+ scroll := update.Scroll
97+ frame.ScrollUp(int(scroll.Top), int(scroll.Top+scroll.Height-1), int(scroll.Count))
98+ }
99+ for _, span := range update.Spans {
100+ copy(frame.Row(int(span.Y))[int(span.X):], span.Cells)
101+ }
102+}
103+
104+func (s *proxyScreenState) recordUpdate(update ports.ScreenUpdate) {
105+ s.generation++
106+ if update.Kind == ports.ScreenUpdateSnapshot {
107+ s.setFullRedraw()
108+ return
109+ }
110+ if s.damageFullRedrawSticky {
111+ return
112+ }
113+ if update.Scroll != nil && len(s.damage) > 0 && !(len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw) {
114+ s.setFullRedraw()
115+ return
116+ }
117+ needed := len(update.Spans)
118+ if update.Scroll != nil {
119+ needed++
120+ }
121+ if needed == 0 {
122+ return
123+ }
124+ if needed > maxProxyScreenDamage-len(s.damage) {
125+ s.setFullRedraw()
126+ return
127+ }
128+ if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
129+ s.damage = s.damage[:0]
130+ }
131+ if update.Scroll != nil {
132+ s.damage = append(s.damage, renderer.Damage{
133+ Kind: renderer.DamageScrollUp,
134+ X: 0,
135+ Y: int(update.Scroll.Top),
136+ Width: s.frame.Width,
137+ Height: int(update.Scroll.Height),
138+ Count: int(update.Scroll.Count),
139+ })
140+ }
141+ for _, span := range update.Spans {
142+ s.damage = append(s.damage, renderer.Damage{
143+ Kind: renderer.DamageText,
144+ X: int(span.X),
145+ Y: int(span.Y),
146+ Width: len(span.Cells),
147+ Height: 1,
148+ Count: 1,
149+ })
150+ }
151+}
152+
153+func (s *proxyScreenState) setFullRedraw() {
154+ s.damage = []renderer.Damage{renderer.FullRedraw()}
155+ s.damageFullRedrawSticky = true
156+}
157+
158+// CaptureDamage returns an owned view of pending damage at one generation.
159+func (s *proxyScreenState) CaptureDamage() proxyScreenDamageCapture {
160+ if s == nil {
161+ return proxyScreenDamageCapture{}
162+ }
163+ return proxyScreenDamageCapture{
164+ Damage: append([]renderer.Damage(nil), s.damage...),
165+ Generation: s.generation,
166+ }
167+}
168+
169+// AcknowledgeDamage consumes damage only for the exact generation captured.
170+// A stale acknowledgement forces a sticky full redraw so intervening updates
171+// cannot be lost.
172+func (s *proxyScreenState) AcknowledgeDamage(generation uint64) bool {
173+ if s == nil {
174+ return false
175+ }
176+ if generation != s.generation {
177+ s.generation++
178+ s.setFullRedraw()
179+ return false
180+ }
181+ s.damage = s.damage[:0]
182+ s.damageFullRedrawSticky = false
183+ return true
184+}
185+
186+// CaptureInto updates dst from the live frame. dst must already contain the
187+// previous synchronized generation before incremental damage can be replayed;
188+// callers should initialize it with a full clone or equivalent first capture.
189+// Safe scroll/span damage is replayed incrementally; snapshots, uncertain
190+// damage, and dimensions use a complete clone.
191+func (s *proxyScreenState) CaptureInto(dst *renderer.Frame) {
192+ if s == nil || dst == nil || !validProxyFrame(*s) {
193+ return
194+ }
195+ if dst.Width != s.frame.Width || dst.Height != s.frame.Height || dst.Validate() != nil {
196+ *dst = s.frame.Clone()
197+ return
198+ }
199+ if len(s.damage) == 0 {
200+ return
201+ }
202+ for _, damage := range s.damage {
203+ switch damage.Kind {
204+ case renderer.DamageScrollUp:
205+ if damage.X != 0 || damage.Width != s.frame.Width || damage.Y < 0 || damage.Height <= 0 || damage.Count <= 0 || damage.Count > damage.Height || damage.Y+damage.Height > s.frame.Height {
206+ *dst = s.frame.Clone()
207+ return
208+ }
209+ dst.ScrollUp(damage.Y, damage.Y+damage.Height-1, damage.Count)
210+ case renderer.DamageText, renderer.DamageClear:
211+ if damage.X < 0 || damage.Y < 0 || damage.Width <= 0 || damage.Height <= 0 || damage.X+damage.Width > s.frame.Width || damage.Y+damage.Height > s.frame.Height {
212+ *dst = s.frame.Clone()
213+ return
214+ }
215+ for y := damage.Y; y < damage.Y+damage.Height; y++ {
216+ copy(dst.Row(y)[damage.X:damage.X+damage.Width], s.frame.Row(y)[damage.X:damage.X+damage.Width])
217+ }
218+ default:
219+ *dst = s.frame.Clone()
220+ return
221+ }
222+ }
223+}
224+
225+// ResizePlaceholder changes geometry while preserving the overlapping logical
226+// cells. It deliberately requests a full redraw and resets the state chain.
227+func (s *proxyScreenState) ResizePlaceholder(size domain.Size) bool {
228+ if s == nil || !validProxyScreenSize(size) {
229+ return false
230+ }
231+ if s.frame.Width == size.Cols && s.frame.Height == size.Rows {
232+ return false
233+ }
234+ next := renderer.NewFrame(size.Cols, size.Rows)
235+ nextScratch := renderer.NewFrame(size.Cols, size.Rows)
236+ copyFrameOverlap(&next, s.frame)
237+ copyFrameOverlap(&nextScratch, s.frame)
238+ s.frame, s.scratch = next, nextScratch
239+ if int(s.cursorOut.Row) >= size.Rows {
240+ s.cursorOut.Row = uint16(size.Rows - 1)
241+ }
242+ if int(s.cursorOut.Col) >= size.Cols {
243+ s.cursorOut.Col = uint16(size.Cols - 1)
244+ }
245+ s.stateNum = 0
246+ s.generation++
247+ s.setFullRedraw()
248+ return true
249+}
250+
251+func copyFrameOverlap(dst *renderer.Frame, src renderer.Frame) {
252+ width := src.Width
253+ if dst.Width < width {
254+ width = dst.Width
255+ }
256+ height := src.Height
257+ if dst.Height < height {
258+ height = dst.Height
259+ }
260+ for y := 0; y < height; y++ {
261+ copy(dst.Row(y)[:width], src.Row(y)[:width])
262+ }
263+}
264+
265+func validProxyScreenSize(size domain.Size) bool {
266+ if size.Cols <= 0 || size.Rows <= 0 || size.Cols > math.MaxUint16 || size.Rows > math.MaxUint16 {
267+ return false
268+ }
269+ return size.Rows <= ports.MaxScreenCells/size.Cols
270+}
271+
272+func validProxyFrame(s proxyScreenState) bool {
273+ return s.frame.Validate() == nil && s.scratch.Validate() == nil
274+}
@@ -0,0 +1,274 @@
1+package daemon
2+
3+import (
4+ "fmt"
5+ "math"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+const maxProxyScreenDamage = 1024
13+
14+type proxyScreenDamageCapture struct {
15+ Damage []renderer.Damage
16+ Generation uint64
17+}
18+
19+// proxyScreenState is the replayable semantic screen owned by a proxy link.
20+// frame and scratch are independent frames and equal whenever Apply returns.
21+// A delta is applied to scratch, the frames are swapped, and the same delta is
22+// applied to the old frame. This avoids copying a complete frame on deltas.
23+type proxyScreenState struct {
24+ frame renderer.Frame
25+ scratch renderer.Frame
26+ cursorOut ports.ScreenCursor
27+
28+ damage []renderer.Damage
29+ generation uint64
30+ damageFullRedrawSticky bool
31+ stateNum uint64
32+}
33+
34+func newProxyScreenState(size domain.Size) *proxyScreenState {
35+ if !validProxyScreenSize(size) {
36+ return &proxyScreenState{}
37+ }
38+ return &proxyScreenState{
39+ frame: renderer.NewFrame(size.Cols, size.Rows),
40+ scratch: renderer.NewFrame(size.Cols, size.Rows),
41+ cursorOut: ports.ScreenCursor{Visible: true},
42+ damage: []renderer.Damage{renderer.FullRedraw()},
43+ generation: 1,
44+ }
45+}
46+
47+// Apply validates the complete semantic update before mutating either frame.
48+func (s *proxyScreenState) Apply(update ports.ScreenUpdate) error {
49+ if s == nil {
50+ return fmt.Errorf("proxy screen state is nil")
51+ }
52+ if err := s.validateUpdate(update); err != nil {
53+ return err
54+ }
55+
56+ if update.Kind == ports.ScreenUpdateSnapshot {
57+ next := renderer.NewFrame(update.Size.Cols, update.Size.Rows)
58+ nextScratch := renderer.NewFrame(update.Size.Cols, update.Size.Rows)
59+ applyScreenUpdate(&next, update)
60+ applyScreenUpdate(&nextScratch, update)
61+ s.frame, s.scratch = next, nextScratch
62+ } else {
63+ applyScreenUpdate(&s.scratch, update)
64+ s.frame, s.scratch = s.scratch, s.frame
65+ applyScreenUpdate(&s.scratch, update)
66+ }
67+ s.cursorOut = update.Cursor
68+ if update.NewStateNum != 0 {
69+ s.stateNum = update.NewStateNum
70+ }
71+ s.recordUpdate(update)
72+ return nil
73+}
74+
75+func (s *proxyScreenState) validateUpdate(update ports.ScreenUpdate) error {
76+ if err := ports.ValidateScreenUpdate(update); err != nil {
77+ return fmt.Errorf("proxy screen: %w", err)
78+ }
79+ if update.Kind == ports.ScreenUpdateDelta {
80+ if s.frame.Width != update.Size.Cols || s.frame.Height != update.Size.Rows {
81+ return fmt.Errorf("proxy screen: delta size %dx%d does not match %dx%d", update.Size.Cols, update.Size.Rows, s.frame.Width, s.frame.Height)
82+ }
83+ }
84+ return s.validateStateNumbers(update)
85+}
86+
87+func (s *proxyScreenState) validateStateNumbers(update ports.ScreenUpdate) error {
88+ if update.Kind == ports.ScreenUpdateDelta && (s.stateNum == 0 || update.BaseStateNum != s.stateNum) {
89+ return fmt.Errorf("proxy screen: delta base state %d does not match %d", update.BaseStateNum, s.stateNum)
90+ }
91+ return nil
92+}
93+
94+func applyScreenUpdate(frame *renderer.Frame, update ports.ScreenUpdate) {
95+ if update.Scroll != nil {
96+ scroll := update.Scroll
97+ frame.ScrollUp(int(scroll.Top), int(scroll.Top+scroll.Height-1), int(scroll.Count))
98+ }
99+ for _, span := range update.Spans {
100+ copy(frame.Row(int(span.Y))[int(span.X):], span.Cells)
101+ }
102+}
103+
104+func (s *proxyScreenState) recordUpdate(update ports.ScreenUpdate) {
105+ s.generation++
106+ if update.Kind == ports.ScreenUpdateSnapshot {
107+ s.setFullRedraw()
108+ return
109+ }
110+ if s.damageFullRedrawSticky {
111+ return
112+ }
113+ if update.Scroll != nil && len(s.damage) > 0 && !(len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw) {
114+ s.setFullRedraw()
115+ return
116+ }
117+ needed := len(update.Spans)
118+ if update.Scroll != nil {
119+ needed++
120+ }
121+ if needed == 0 {
122+ return
123+ }
124+ if needed > maxProxyScreenDamage-len(s.damage) {
125+ s.setFullRedraw()
126+ return
127+ }
128+ if len(s.damage) == 1 && s.damage[0].Kind == renderer.DamageFullRedraw {
129+ s.damage = s.damage[:0]
130+ }
131+ if update.Scroll != nil {
132+ s.damage = append(s.damage, renderer.Damage{
133+ Kind: renderer.DamageScrollUp,
134+ X: 0,
135+ Y: int(update.Scroll.Top),
136+ Width: s.frame.Width,
137+ Height: int(update.Scroll.Height),
138+ Count: int(update.Scroll.Count),
139+ })
140+ }
141+ for _, span := range update.Spans {
142+ s.damage = append(s.damage, renderer.Damage{
143+ Kind: renderer.DamageText,
144+ X: int(span.X),
145+ Y: int(span.Y),
146+ Width: len(span.Cells),
147+ Height: 1,
148+ Count: 1,
149+ })
150+ }
151+}
152+
153+func (s *proxyScreenState) setFullRedraw() {
154+ s.damage = []renderer.Damage{renderer.FullRedraw()}
155+ s.damageFullRedrawSticky = true
156+}
157+
158+// CaptureDamage returns an owned view of pending damage at one generation.
159+func (s *proxyScreenState) CaptureDamage() proxyScreenDamageCapture {
160+ if s == nil {
161+ return proxyScreenDamageCapture{}
162+ }
163+ return proxyScreenDamageCapture{
164+ Damage: append([]renderer.Damage(nil), s.damage...),
165+ Generation: s.generation,
166+ }
167+}
168+
169+// AcknowledgeDamage consumes damage only for the exact generation captured.
170+// A stale acknowledgement forces a sticky full redraw so intervening updates
171+// cannot be lost.
172+func (s *proxyScreenState) AcknowledgeDamage(generation uint64) bool {
173+ if s == nil {
174+ return false
175+ }
176+ if generation != s.generation {
177+ s.generation++
178+ s.setFullRedraw()
179+ return false
180+ }
181+ s.damage = s.damage[:0]
182+ s.damageFullRedrawSticky = false
183+ return true
184+}
185+
186+// CaptureInto updates dst from the live frame. dst must already contain the
187+// previous synchronized generation before incremental damage can be replayed;
188+// callers should initialize it with a full clone or equivalent first capture.
189+// Safe scroll/span damage is replayed incrementally; snapshots, uncertain
190+// damage, and dimensions use a complete clone.
191+func (s *proxyScreenState) CaptureInto(dst *renderer.Frame) {
192+ if s == nil || dst == nil || !validProxyFrame(*s) {
193+ return
194+ }
195+ if dst.Width != s.frame.Width || dst.Height != s.frame.Height || dst.Validate() != nil {
196+ *dst = s.frame.Clone()
197+ return
198+ }
199+ if len(s.damage) == 0 {
200+ return
201+ }
202+ for _, damage := range s.damage {
203+ switch damage.Kind {
204+ case renderer.DamageScrollUp:
205+ if damage.X != 0 || damage.Width != s.frame.Width || damage.Y < 0 || damage.Height <= 0 || damage.Count <= 0 || damage.Count > damage.Height || damage.Y+damage.Height > s.frame.Height {
206+ *dst = s.frame.Clone()
207+ return
208+ }
209+ dst.ScrollUp(damage.Y, damage.Y+damage.Height-1, damage.Count)
210+ case renderer.DamageText, renderer.DamageClear:
211+ if damage.X < 0 || damage.Y < 0 || damage.Width <= 0 || damage.Height <= 0 || damage.X+damage.Width > s.frame.Width || damage.Y+damage.Height > s.frame.Height {
212+ *dst = s.frame.Clone()
213+ return
214+ }
215+ for y := damage.Y; y < damage.Y+damage.Height; y++ {
216+ copy(dst.Row(y)[damage.X:damage.X+damage.Width], s.frame.Row(y)[damage.X:damage.X+damage.Width])
217+ }
218+ default:
219+ *dst = s.frame.Clone()
220+ return
221+ }
222+ }
223+}
224+
225+// ResizePlaceholder changes geometry while preserving the overlapping logical
226+// cells. It deliberately requests a full redraw and resets the state chain.
227+func (s *proxyScreenState) ResizePlaceholder(size domain.Size) bool {
228+ if s == nil || !validProxyScreenSize(size) {
229+ return false
230+ }
231+ if s.frame.Width == size.Cols && s.frame.Height == size.Rows {
232+ return false
233+ }
234+ next := renderer.NewFrame(size.Cols, size.Rows)
235+ nextScratch := renderer.NewFrame(size.Cols, size.Rows)
236+ copyFrameOverlap(&next, s.frame)
237+ copyFrameOverlap(&nextScratch, s.frame)
238+ s.frame, s.scratch = next, nextScratch
239+ if int(s.cursorOut.Row) >= size.Rows {
240+ s.cursorOut.Row = uint16(size.Rows - 1)
241+ }
242+ if int(s.cursorOut.Col) >= size.Cols {
243+ s.cursorOut.Col = uint16(size.Cols - 1)
244+ }
245+ s.stateNum = 0
246+ s.generation++
247+ s.setFullRedraw()
248+ return true
249+}
250+
251+func copyFrameOverlap(dst *renderer.Frame, src renderer.Frame) {
252+ width := src.Width
253+ if dst.Width < width {
254+ width = dst.Width
255+ }
256+ height := src.Height
257+ if dst.Height < height {
258+ height = dst.Height
259+ }
260+ for y := 0; y < height; y++ {
261+ copy(dst.Row(y)[:width], src.Row(y)[:width])
262+ }
263+}
264+
265+func validProxyScreenSize(size domain.Size) bool {
266+ if size.Cols <= 0 || size.Rows <= 0 || size.Cols > math.MaxUint16 || size.Rows > math.MaxUint16 {
267+ return false
268+ }
269+ return size.Rows <= ports.MaxScreenCells/size.Cols
270+}
271+
272+func validProxyFrame(s proxyScreenState) bool {
273+ return s.frame.Validate() == nil && s.scratch.Validate() == nil
274+}
A internal/usecase/daemon/proxy_screen_state_test.go +471 -0
@@ -0,0 +1,471 @@
1+package daemon
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+func TestProxyScreenStateApplyIsAtomicAndReplaysScrolls(t *testing.T) {
13+ s := newProxyScreenState(domain.Size{Cols: 4, Rows: 3})
14+ if err := s.Apply(screenSnapshot(4, 3, "abcd", "efgh", "ijkl")); err != nil {
15+ t.Fatal(err)
16+ }
17+ if !reflect.DeepEqual(s.frame, s.scratch) {
18+ t.Fatal("live and scratch frames diverged after snapshot")
19+ }
20+ applyProxyDelta(t, s, ports.ScreenUpdate{
21+ Size: domain.Size{Cols: 4, Rows: 3},
22+ Scroll: &ports.ScreenScroll{Top: 0, Height: 3, Count: 1},
23+ Spans: []ports.ScreenSpan{{Y: 2, Cells: cells("WXYZ")}},
24+ })
25+ for range 7 {
26+ applyProxyDelta(t, s, ports.ScreenUpdate{
27+ Size: domain.Size{Cols: 4, Rows: 3},
28+ Scroll: &ports.ScreenScroll{Top: 0, Height: 3, Count: 1},
29+ Spans: []ports.ScreenSpan{{Y: 2, Cells: cells("WXYZ")}},
30+ })
31+ }
32+ if !reflect.DeepEqual(s.frame, s.scratch) {
33+ t.Fatal("live and scratch frames diverged after repeated scrolls")
34+ }
35+ if got := proxyFrameText(s.frame); got != "WXYZWXYZWXYZ" {
36+ t.Fatalf("replayed scroll state = %q", got)
37+ }
38+}
39+
40+func TestProxyScreenStateInvalidUpdateIsAtomic(t *testing.T) {
41+ s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2})
42+ if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil {
43+ t.Fatal(err)
44+ }
45+ beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone()
46+ beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum
47+ invalid := ports.ScreenUpdate{
48+ Kind: ports.ScreenUpdateDelta,
49+ BaseStateNum: 1,
50+ NewStateNum: 2,
51+ Size: domain.Size{Cols: 4, Rows: 2},
52+ Scroll: &ports.ScreenScroll{Top: 1, Height: 2, Count: 1},
53+ Spans: []ports.ScreenSpan{{Y: 2, Cells: cells("x")}},
54+ Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true},
55+ }
56+ if err := s.Apply(invalid); err == nil {
57+ t.Fatal("invalid geometry was accepted")
58+ }
59+ if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) {
60+ t.Fatal("invalid update mutated a frame")
61+ }
62+ if s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState {
63+ t.Fatal("invalid update mutated metadata")
64+ }
65+
66+ invalid = ports.ScreenUpdate{
67+ Kind: ports.ScreenUpdateDelta,
68+ BaseStateNum: 1,
69+ NewStateNum: 2,
70+ Size: domain.Size{Cols: 3, Rows: 2},
71+ Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true},
72+ }
73+ if err := s.Apply(invalid); err == nil {
74+ t.Fatal("delta with mismatched dimensions was accepted")
75+ }
76+ if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState {
77+ t.Fatal("mismatched-dimension update mutated state")
78+ }
79+
80+ invalid = ports.ScreenUpdate{
81+ Kind: ports.ScreenUpdateDelta,
82+ BaseStateNum: 1,
83+ NewStateNum: 2,
84+ Size: domain.Size{Cols: 4, Rows: 2},
85+ Scroll: &ports.ScreenScroll{Top: 0, Height: 2, Count: 2},
86+ Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true},
87+ }
88+ if err := s.Apply(invalid); err == nil {
89+ t.Fatal("full-height scroll was accepted")
90+ }
91+ if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState {
92+ t.Fatal("invalid full-height scroll mutated state")
93+ }
94+}
95+
96+func TestProxyScreenStateScreenAreaLimitIsAtomic(t *testing.T) {
97+ boundary := newProxyScreenState(domain.Size{Cols: 512, Rows: 512})
98+ if boundary.frame.Width != 512 || boundary.frame.Height != 512 || boundary.scratch.Width != 512 || boundary.scratch.Height != 512 {
99+ t.Fatalf("boundary state dimensions = %dx%d and %dx%d", boundary.frame.Width, boundary.frame.Height, boundary.scratch.Width, boundary.scratch.Height)
100+ }
101+ if boundary.ResizePlaceholder(domain.Size{Cols: 513, Rows: 512}) {
102+ t.Fatal("over-cap resize was accepted")
103+ }
104+ if boundary.frame.Width != 512 || boundary.frame.Height != 512 || boundary.scratch.Width != 512 || boundary.scratch.Height != 512 {
105+ t.Fatal("over-cap resize changed boundary state")
106+ }
107+
108+ s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
109+ if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil {
110+ t.Fatal(err)
111+ }
112+ beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone()
113+ beforeCursor, beforeGeneration, beforeState, beforeDamage := s.cursorOut, s.generation, s.stateNum, s.CaptureDamage()
114+ if err := s.Apply(ports.ScreenUpdate{
115+ NewStateNum: 2,
116+ Kind: ports.ScreenUpdateSnapshot,
117+ Size: domain.Size{Cols: 513, Rows: 512},
118+ Cursor: ports.ScreenCursor{Visible: true},
119+ }); err == nil {
120+ t.Fatal("over-cap snapshot was accepted")
121+ }
122+ if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState || !reflect.DeepEqual(s.CaptureDamage(), beforeDamage) {
123+ t.Fatal("over-cap snapshot mutated state")
124+ }
125+ if got := newProxyScreenState(domain.Size{Cols: 513, Rows: 512}); got.frame.Validate() == nil || got.scratch.Validate() == nil {
126+ t.Fatal("over-cap constructor allocated a valid frame")
127+ }
128+}
129+
130+func TestProxyScreenStateRejectsInvalidStateNumbersAtomically(t *testing.T) {
131+ s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
132+ if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil {
133+ t.Fatal(err)
134+ }
135+ if got := proxyFrameText(s.frame); got != "abcd" || !reflect.DeepEqual(s.frame, s.scratch) {
136+ t.Fatalf("2x2 snapshot = %q, frames equal=%v", got, reflect.DeepEqual(s.frame, s.scratch))
137+ }
138+ updates := []ports.ScreenUpdate{
139+ func() ports.ScreenUpdate {
140+ m := screenSnapshot(2, 2, "ef", "gh")
141+ m.NewStateNum = 0
142+ return m
143+ }(),
144+ func() ports.ScreenUpdate {
145+ m := screenSnapshot(2, 2, "ef", "gh")
146+ m.BaseStateNum, m.NewStateNum = 1, 2
147+ return m
148+ }(),
149+ {Kind: ports.ScreenUpdateDelta, BaseStateNum: 0, NewStateNum: 0, Size: domain.Size{Cols: 2, Rows: 2}},
150+ {Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 3, Size: domain.Size{Cols: 2, Rows: 2}},
151+ {Kind: ports.ScreenUpdateDelta, BaseStateNum: 2, NewStateNum: 3, Size: domain.Size{Cols: 2, Rows: 2}},
152+ }
153+ for i, update := range updates {
154+ beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone()
155+ beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum
156+ if err := s.Apply(update); err == nil {
157+ t.Fatalf("invalid state update %d was accepted", i)
158+ }
159+ if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState {
160+ t.Fatalf("invalid state update %d mutated live state", i)
161+ }
162+ }
163+}
164+
165+func TestProxyScreenStateRejectsWireInvalidSemanticUpdatesAtomically(t *testing.T) {
166+ s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
167+ if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil {
168+ t.Fatal(err)
169+ }
170+ assertUnchanged := func(beforeFrame, beforeScratch renderer.Frame, beforeCursor ports.ScreenCursor, beforeGeneration, beforeState uint64, beforeDamage proxyScreenDamageCapture) {
171+ t.Helper()
172+ if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState || !reflect.DeepEqual(s.CaptureDamage(), beforeDamage) {
173+ t.Fatal("invalid semantic update mutated live state")
174+ }
175+ }
176+
177+ beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone()
178+ beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum
179+ beforeDamage := s.CaptureDamage()
180+ invalidCursor := nextProxyDelta(s, ports.ScreenUpdate{
181+ Size: domain.Size{Cols: 2, Rows: 2},
182+ Cursor: ports.ScreenCursor{Style: 7, StyleSet: true},
183+ })
184+ if err := s.Apply(invalidCursor); err == nil {
185+ t.Fatal("cursor style 7 was accepted")
186+ }
187+ assertUnchanged(beforeFrame, beforeScratch, beforeCursor, beforeGeneration, beforeState, beforeDamage)
188+
189+ beforeFrame, beforeScratch = s.frame.Clone(), s.scratch.Clone()
190+ beforeCursor, beforeGeneration, beforeState = s.cursorOut, s.generation, s.stateNum
191+ beforeDamage = s.CaptureDamage()
192+ invalidSpans := nextProxyDelta(s, ports.ScreenUpdate{
193+ Size: domain.Size{Cols: 2, Rows: 2},
194+ Spans: make([]ports.ScreenSpan, ports.MaxScreenSpans+1),
195+ })
196+ if err := s.Apply(invalidSpans); err == nil {
197+ t.Fatal("too many spans were accepted")
198+ }
199+ assertUnchanged(beforeFrame, beforeScratch, beforeCursor, beforeGeneration, beforeState, beforeDamage)
200+}
201+
202+func TestProxyScreenStateAcceptsConsecutiveHorizontalSpans(t *testing.T) {
203+ s := newProxyScreenState(domain.Size{Cols: 4, Rows: 1})
204+ if err := s.Apply(screenSnapshot(4, 1, "abcd")); err != nil {
205+ t.Fatal(err)
206+ }
207+ applyProxyDelta(t, s, ports.ScreenUpdate{
208+ Size: domain.Size{Cols: 4, Rows: 1},
209+ Spans: []ports.ScreenSpan{
210+ {Y: 0, X: 0, Cells: cells("xy")},
211+ {Y: 0, X: 2, Cells: cells("Zq")},
212+ },
213+ })
214+ if got := proxyFrameText(s.frame); got != "xyZq" || !reflect.DeepEqual(s.frame, s.scratch) {
215+ t.Fatalf("adjacent spans replay = %q, frames equal=%v", got, reflect.DeepEqual(s.frame, s.scratch))
216+ }
217+}
218+
219+func TestProxyScreenStateDamageGenerationAndCapture(t *testing.T) {
220+ s := newProxyScreenState(domain.Size{Cols: 3, Rows: 2})
221+ if err := s.Apply(screenSnapshot(3, 2, " ", " ")); err != nil {
222+ t.Fatal(err)
223+ }
224+ first := s.CaptureDamage()
225+ if len(first.Damage) != 1 || first.Damage[0].Kind != renderer.DamageFullRedraw {
226+ t.Fatalf("initial damage = %+v", first.Damage)
227+ }
228+ if !s.AcknowledgeDamage(first.Generation) {
229+ t.Fatal("exact initial acknowledgement failed")
230+ }
231+ if len(s.CaptureDamage().Damage) != 0 {
232+ t.Fatal("exact acknowledgement left damage")
233+ }
234+ applyProxyDelta(t, s, ports.ScreenUpdate{
235+ Size: domain.Size{Cols: 3, Rows: 2},
236+ Spans: []ports.ScreenSpan{{Y: 1, Cells: cells("xyz")}},
237+ })
238+ captured := s.CaptureDamage()
239+ if len(captured.Damage) != 1 || captured.Damage[0].Width != 3 {
240+ t.Fatalf("span damage = %+v", captured.Damage)
241+ }
242+ if s.AcknowledgeDamage(captured.Generation - 1) {
243+ t.Fatal("stale acknowledgement succeeded")
244+ }
245+ forced := s.CaptureDamage()
246+ if len(forced.Damage) != 1 || forced.Damage[0].Kind != renderer.DamageFullRedraw {
247+ t.Fatalf("stale acknowledgement damage = %+v", forced.Damage)
248+ }
249+ if !s.AcknowledgeDamage(forced.Generation) || len(s.CaptureDamage().Damage) != 0 {
250+ t.Fatal("full redraw did not clear on exact acknowledgement")
251+ }
252+}
253+
254+func TestProxyScreenStateCaptureAndResize(t *testing.T) {
255+ s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2})
256+ if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil {
257+ t.Fatal(err)
258+ }
259+ _ = s.AcknowledgeDamage(s.CaptureDamage().Generation)
260+ var dst renderer.Frame
261+ s.CaptureInto(&dst)
262+ if got := proxyFrameText(dst); got != "abcdefgh" {
263+ t.Fatalf("captured frame = %q", got)
264+ }
265+ applyProxyDelta(t, s, ports.ScreenUpdate{
266+ Size: domain.Size{Cols: 4, Rows: 2},
267+ Spans: []ports.ScreenSpan{{Y: 0, X: 1, Cells: cells("Z")}},
268+ })
269+ s.CaptureInto(&dst)
270+ if got := proxyFrameText(dst); got != "aZcdefgh" {
271+ t.Fatalf("incrementally captured frame = %q", got)
272+ }
273+ if !s.ResizePlaceholder(domain.Size{Cols: 3, Rows: 3}) {
274+ t.Fatal("resize placeholder rejected valid size")
275+ }
276+ if got := proxyFrameText(s.frame); got != "aZcefg " {
277+ t.Fatalf("overlap-preserving resize = %q", got)
278+ }
279+ capture := s.CaptureDamage()
280+ if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw {
281+ t.Fatalf("resize damage = %+v", capture.Damage)
282+ }
283+}
284+
285+func TestProxyScreenStateCaptureFallsBackAfterSpanThenScroll(t *testing.T) {
286+ s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2})
287+ if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil {
288+ t.Fatal(err)
289+ }
290+ _ = s.AcknowledgeDamage(s.CaptureDamage().Generation)
291+ dst := s.frame.Clone()
292+ applyProxyDelta(t, s, ports.ScreenUpdate{
293+ Size: domain.Size{Cols: 4, Rows: 2},
294+ Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("Z")}},
295+ })
296+ applyProxyDelta(t, s, ports.ScreenUpdate{
297+ Size: domain.Size{Cols: 4, Rows: 2},
298+ Scroll: &ports.ScreenScroll{Top: 0, Height: 2, Count: 1},
299+ Spans: []ports.ScreenSpan{{Y: 1, Cells: cells("IJKL")}},
300+ })
301+ capture := s.CaptureDamage()
302+ if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw {
303+ t.Fatalf("span-then-scroll damage = %+v", capture.Damage)
304+ }
305+ s.CaptureInto(&dst)
306+ if !reflect.DeepEqual(dst, s.frame.Clone()) {
307+ t.Fatal("span-then-scroll capture diverged from live frame")
308+ }
309+}
310+
311+func TestProxyScreenStateCaptureFallsBackAfterRepeatedScroll(t *testing.T) {
312+ s := newProxyScreenState(domain.Size{Cols: 4, Rows: 3})
313+ if err := s.Apply(screenSnapshot(4, 3, "abcd", "efgh", "ijkl")); err != nil {
314+ t.Fatal(err)
315+ }
316+ _ = s.AcknowledgeDamage(s.CaptureDamage().Generation)
317+ dst := s.frame.Clone()
318+ scroll := func(line string) {
319+ t.Helper()
320+ applyProxyDelta(t, s, ports.ScreenUpdate{
321+ Size: domain.Size{Cols: 4, Rows: 3},
322+ Scroll: &ports.ScreenScroll{Top: 0, Height: 3, Count: 1},
323+ Spans: []ports.ScreenSpan{{Y: 2, Cells: cells(line)}},
324+ })
325+ }
326+ scroll("WXYZ")
327+ if len(s.CaptureDamage().Damage) != 2 {
328+ t.Fatalf("first scroll damage = %+v", s.CaptureDamage().Damage)
329+ }
330+ scroll("QRST")
331+ capture := s.CaptureDamage()
332+ if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw {
333+ t.Fatalf("repeated-scroll damage = %+v", capture.Damage)
334+ }
335+ s.CaptureInto(&dst)
336+ if !reflect.DeepEqual(dst, s.frame.Clone()) {
337+ t.Fatal("repeated-scroll capture diverged from live frame")
338+ }
339+}
340+
341+func TestProxyScreenStateCursorOnlyAndDamageBound(t *testing.T) {
342+ s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
343+ if err := s.Apply(screenSnapshot(2, 2, " ", " ")); err != nil {
344+ t.Fatal(err)
345+ }
346+ _ = s.AcknowledgeDamage(s.generation)
347+ applyProxyDelta(t, s, ports.ScreenUpdate{
348+ Size: domain.Size{Cols: 2, Rows: 2},
349+ Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true},
350+ })
351+ if s.cursorOut.Row != 1 || s.cursorOut.Col != 1 || len(s.damage) != 0 {
352+ t.Fatalf("cursor-only update = cursor=%+v damage=%+v", s.cursorOut, s.damage)
353+ }
354+ for i := 0; i < maxProxyScreenDamage; i++ {
355+ applyProxyDelta(t, s, ports.ScreenUpdate{
356+ Size: domain.Size{Cols: 2, Rows: 2},
357+ Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("x")}},
358+ })
359+ }
360+ if len(s.damage) != maxProxyScreenDamage {
361+ t.Fatalf("damage length = %d, want %d", len(s.damage), maxProxyScreenDamage)
362+ }
363+ applyProxyDelta(t, s, ports.ScreenUpdate{
364+ Size: domain.Size{Cols: 2, Rows: 2},
365+ Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("y")}},
366+ })
367+ if len(s.damage) != 1 || s.damage[0].Kind != renderer.DamageFullRedraw {
368+ t.Fatalf("saturated damage = %+v", s.damage)
369+ }
370+}
371+
372+func BenchmarkProxyScreenStateApply(b *testing.B) {
373+ size := domain.Size{Cols: 120, Rows: 40}
374+ cases := []struct {
375+ name string
376+ update ports.ScreenUpdate
377+ cells, spans int
378+ }{
379+ {name: "one-cell", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 1, X: 0, Cells: cells("x")}}}, cells: 1, spans: 1},
380+ {name: "full-line", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 2, Cells: cellsRepeat('x', 120)}}}, cells: 120, spans: 1},
381+ {name: "styled-fragments", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 3, Cells: styledCells(120)}}}, cells: 120, spans: 1},
382+ {name: "full-screen-scroll", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Scroll: &ports.ScreenScroll{Top: 0, Height: 40, Count: 1}, Spans: []ports.ScreenSpan{{Y: 39, Cells: cellsRepeat('x', 120)}}}, cells: 120 * 40, spans: 2},
383+ }
384+ for _, tc := range cases {
385+ b.Run(tc.name, func(b *testing.B) {
386+ s := newProxyScreenState(size)
387+ if err := s.Apply(blankSnapshot(size)); err != nil {
388+ b.Fatal(err)
389+ }
390+ _ = s.AcknowledgeDamage(s.generation)
391+ // Warm the bounded damage buffer before timing the replay loop.
392+ if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil {
393+ b.Fatal(err)
394+ }
395+ _ = s.AcknowledgeDamage(s.generation)
396+ b.ResetTimer()
397+ b.ReportMetric(float64(tc.cells), "cells/op")
398+ b.ReportMetric(float64(tc.spans), "spans/op")
399+ for range b.N {
400+ if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil {
401+ b.Fatal(err)
402+ }
403+ _ = s.AcknowledgeDamage(s.generation)
404+ }
405+ })
406+ }
407+}
408+
409+func applyProxyDelta(t *testing.T, s *proxyScreenState, update ports.ScreenUpdate) {
410+ t.Helper()
411+ if err := s.Apply(nextProxyDelta(s, update)); err != nil {
412+ t.Fatal(err)
413+ }
414+}
415+
416+func nextProxyDelta(s *proxyScreenState, update ports.ScreenUpdate) ports.ScreenUpdate {
417+ update.Kind = ports.ScreenUpdateDelta
418+ update.BaseStateNum = s.stateNum
419+ update.NewStateNum = s.stateNum + 1
420+ return update
421+}
422+
423+func screenSnapshot(cols, rows int, lines ...string) ports.ScreenUpdate {
424+ spans := make([]ports.ScreenSpan, len(lines))
425+ for y, line := range lines {
426+ spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cells(line)}
427+ }
428+ return ports.ScreenUpdate{NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: domain.Size{Cols: cols, Rows: rows}, Spans: spans}
429+}
430+
431+func blankSnapshot(size domain.Size) ports.ScreenUpdate {
432+ spans := make([]ports.ScreenSpan, size.Rows)
433+ for y := range spans {
434+ spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cellsRepeat(' ', size.Cols)}
435+ }
436+ return ports.ScreenUpdate{NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: size, Spans: spans}
437+}
438+
439+func cells(text string) []renderer.Cell {
440+ out := make([]renderer.Cell, 0, len(text))
441+ for _, r := range text {
442+ out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
443+ }
444+ return out
445+}
446+
447+func cellsRepeat(r rune, n int) []renderer.Cell {
448+ out := make([]renderer.Cell, n)
449+ for i := range out {
450+ out[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
451+ }
452+ return out
453+}
454+
455+func styledCells(n int) []renderer.Cell {
456+ out := cellsRepeat('s', n)
457+ for i := range out {
458+ out[i].Style.Bold = i%2 == 0
459+ }
460+ return out
461+}
462+
463+func proxyFrameText(frame renderer.Frame) string {
464+ out := make([]rune, 0, frame.Width*frame.Height)
465+ for y := 0; y < frame.Height; y++ {
466+ for _, cell := range frame.Row(y) {
467+ out = append(out, cell.Rune)
468+ }
469+ }
470+ return string(out)
471+}
@@ -0,0 +1,471 @@
1+package daemon
2+
3+import (
4+ "reflect"
5+ "testing"
6+
7+ "github.com/bnema/vev/internal/domain"
8+ "github.com/bnema/vev/internal/ports"
9+ "github.com/bnema/vev/pkg/renderer"
10+)
11+
12+func TestProxyScreenStateApplyIsAtomicAndReplaysScrolls(t *testing.T) {
13+ s := newProxyScreenState(domain.Size{Cols: 4, Rows: 3})
14+ if err := s.Apply(screenSnapshot(4, 3, "abcd", "efgh", "ijkl")); err != nil {
15+ t.Fatal(err)
16+ }
17+ if !reflect.DeepEqual(s.frame, s.scratch) {
18+ t.Fatal("live and scratch frames diverged after snapshot")
19+ }
20+ applyProxyDelta(t, s, ports.ScreenUpdate{
21+ Size: domain.Size{Cols: 4, Rows: 3},
22+ Scroll: &ports.ScreenScroll{Top: 0, Height: 3, Count: 1},
23+ Spans: []ports.ScreenSpan{{Y: 2, Cells: cells("WXYZ")}},
24+ })
25+ for range 7 {
26+ applyProxyDelta(t, s, ports.ScreenUpdate{
27+ Size: domain.Size{Cols: 4, Rows: 3},
28+ Scroll: &ports.ScreenScroll{Top: 0, Height: 3, Count: 1},
29+ Spans: []ports.ScreenSpan{{Y: 2, Cells: cells("WXYZ")}},
30+ })
31+ }
32+ if !reflect.DeepEqual(s.frame, s.scratch) {
33+ t.Fatal("live and scratch frames diverged after repeated scrolls")
34+ }
35+ if got := proxyFrameText(s.frame); got != "WXYZWXYZWXYZ" {
36+ t.Fatalf("replayed scroll state = %q", got)
37+ }
38+}
39+
40+func TestProxyScreenStateInvalidUpdateIsAtomic(t *testing.T) {
41+ s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2})
42+ if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil {
43+ t.Fatal(err)
44+ }
45+ beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone()
46+ beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum
47+ invalid := ports.ScreenUpdate{
48+ Kind: ports.ScreenUpdateDelta,
49+ BaseStateNum: 1,
50+ NewStateNum: 2,
51+ Size: domain.Size{Cols: 4, Rows: 2},
52+ Scroll: &ports.ScreenScroll{Top: 1, Height: 2, Count: 1},
53+ Spans: []ports.ScreenSpan{{Y: 2, Cells: cells("x")}},
54+ Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true},
55+ }
56+ if err := s.Apply(invalid); err == nil {
57+ t.Fatal("invalid geometry was accepted")
58+ }
59+ if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) {
60+ t.Fatal("invalid update mutated a frame")
61+ }
62+ if s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState {
63+ t.Fatal("invalid update mutated metadata")
64+ }
65+
66+ invalid = ports.ScreenUpdate{
67+ Kind: ports.ScreenUpdateDelta,
68+ BaseStateNum: 1,
69+ NewStateNum: 2,
70+ Size: domain.Size{Cols: 3, Rows: 2},
71+ Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true},
72+ }
73+ if err := s.Apply(invalid); err == nil {
74+ t.Fatal("delta with mismatched dimensions was accepted")
75+ }
76+ if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState {
77+ t.Fatal("mismatched-dimension update mutated state")
78+ }
79+
80+ invalid = ports.ScreenUpdate{
81+ Kind: ports.ScreenUpdateDelta,
82+ BaseStateNum: 1,
83+ NewStateNum: 2,
84+ Size: domain.Size{Cols: 4, Rows: 2},
85+ Scroll: &ports.ScreenScroll{Top: 0, Height: 2, Count: 2},
86+ Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true},
87+ }
88+ if err := s.Apply(invalid); err == nil {
89+ t.Fatal("full-height scroll was accepted")
90+ }
91+ if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState {
92+ t.Fatal("invalid full-height scroll mutated state")
93+ }
94+}
95+
96+func TestProxyScreenStateScreenAreaLimitIsAtomic(t *testing.T) {
97+ boundary := newProxyScreenState(domain.Size{Cols: 512, Rows: 512})
98+ if boundary.frame.Width != 512 || boundary.frame.Height != 512 || boundary.scratch.Width != 512 || boundary.scratch.Height != 512 {
99+ t.Fatalf("boundary state dimensions = %dx%d and %dx%d", boundary.frame.Width, boundary.frame.Height, boundary.scratch.Width, boundary.scratch.Height)
100+ }
101+ if boundary.ResizePlaceholder(domain.Size{Cols: 513, Rows: 512}) {
102+ t.Fatal("over-cap resize was accepted")
103+ }
104+ if boundary.frame.Width != 512 || boundary.frame.Height != 512 || boundary.scratch.Width != 512 || boundary.scratch.Height != 512 {
105+ t.Fatal("over-cap resize changed boundary state")
106+ }
107+
108+ s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
109+ if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil {
110+ t.Fatal(err)
111+ }
112+ beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone()
113+ beforeCursor, beforeGeneration, beforeState, beforeDamage := s.cursorOut, s.generation, s.stateNum, s.CaptureDamage()
114+ if err := s.Apply(ports.ScreenUpdate{
115+ NewStateNum: 2,
116+ Kind: ports.ScreenUpdateSnapshot,
117+ Size: domain.Size{Cols: 513, Rows: 512},
118+ Cursor: ports.ScreenCursor{Visible: true},
119+ }); err == nil {
120+ t.Fatal("over-cap snapshot was accepted")
121+ }
122+ if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState || !reflect.DeepEqual(s.CaptureDamage(), beforeDamage) {
123+ t.Fatal("over-cap snapshot mutated state")
124+ }
125+ if got := newProxyScreenState(domain.Size{Cols: 513, Rows: 512}); got.frame.Validate() == nil || got.scratch.Validate() == nil {
126+ t.Fatal("over-cap constructor allocated a valid frame")
127+ }
128+}
129+
130+func TestProxyScreenStateRejectsInvalidStateNumbersAtomically(t *testing.T) {
131+ s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
132+ if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil {
133+ t.Fatal(err)
134+ }
135+ if got := proxyFrameText(s.frame); got != "abcd" || !reflect.DeepEqual(s.frame, s.scratch) {
136+ t.Fatalf("2x2 snapshot = %q, frames equal=%v", got, reflect.DeepEqual(s.frame, s.scratch))
137+ }
138+ updates := []ports.ScreenUpdate{
139+ func() ports.ScreenUpdate {
140+ m := screenSnapshot(2, 2, "ef", "gh")
141+ m.NewStateNum = 0
142+ return m
143+ }(),
144+ func() ports.ScreenUpdate {
145+ m := screenSnapshot(2, 2, "ef", "gh")
146+ m.BaseStateNum, m.NewStateNum = 1, 2
147+ return m
148+ }(),
149+ {Kind: ports.ScreenUpdateDelta, BaseStateNum: 0, NewStateNum: 0, Size: domain.Size{Cols: 2, Rows: 2}},
150+ {Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 3, Size: domain.Size{Cols: 2, Rows: 2}},
151+ {Kind: ports.ScreenUpdateDelta, BaseStateNum: 2, NewStateNum: 3, Size: domain.Size{Cols: 2, Rows: 2}},
152+ }
153+ for i, update := range updates {
154+ beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone()
155+ beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum
156+ if err := s.Apply(update); err == nil {
157+ t.Fatalf("invalid state update %d was accepted", i)
158+ }
159+ if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState {
160+ t.Fatalf("invalid state update %d mutated live state", i)
161+ }
162+ }
163+}
164+
165+func TestProxyScreenStateRejectsWireInvalidSemanticUpdatesAtomically(t *testing.T) {
166+ s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
167+ if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil {
168+ t.Fatal(err)
169+ }
170+ assertUnchanged := func(beforeFrame, beforeScratch renderer.Frame, beforeCursor ports.ScreenCursor, beforeGeneration, beforeState uint64, beforeDamage proxyScreenDamageCapture) {
171+ t.Helper()
172+ if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState || !reflect.DeepEqual(s.CaptureDamage(), beforeDamage) {
173+ t.Fatal("invalid semantic update mutated live state")
174+ }
175+ }
176+
177+ beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone()
178+ beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum
179+ beforeDamage := s.CaptureDamage()
180+ invalidCursor := nextProxyDelta(s, ports.ScreenUpdate{
181+ Size: domain.Size{Cols: 2, Rows: 2},
182+ Cursor: ports.ScreenCursor{Style: 7, StyleSet: true},
183+ })
184+ if err := s.Apply(invalidCursor); err == nil {
185+ t.Fatal("cursor style 7 was accepted")
186+ }
187+ assertUnchanged(beforeFrame, beforeScratch, beforeCursor, beforeGeneration, beforeState, beforeDamage)
188+
189+ beforeFrame, beforeScratch = s.frame.Clone(), s.scratch.Clone()
190+ beforeCursor, beforeGeneration, beforeState = s.cursorOut, s.generation, s.stateNum
191+ beforeDamage = s.CaptureDamage()
192+ invalidSpans := nextProxyDelta(s, ports.ScreenUpdate{
193+ Size: domain.Size{Cols: 2, Rows: 2},
194+ Spans: make([]ports.ScreenSpan, ports.MaxScreenSpans+1),
195+ })
196+ if err := s.Apply(invalidSpans); err == nil {
197+ t.Fatal("too many spans were accepted")
198+ }
199+ assertUnchanged(beforeFrame, beforeScratch, beforeCursor, beforeGeneration, beforeState, beforeDamage)
200+}
201+
202+func TestProxyScreenStateAcceptsConsecutiveHorizontalSpans(t *testing.T) {
203+ s := newProxyScreenState(domain.Size{Cols: 4, Rows: 1})
204+ if err := s.Apply(screenSnapshot(4, 1, "abcd")); err != nil {
205+ t.Fatal(err)
206+ }
207+ applyProxyDelta(t, s, ports.ScreenUpdate{
208+ Size: domain.Size{Cols: 4, Rows: 1},
209+ Spans: []ports.ScreenSpan{
210+ {Y: 0, X: 0, Cells: cells("xy")},
211+ {Y: 0, X: 2, Cells: cells("Zq")},
212+ },
213+ })
214+ if got := proxyFrameText(s.frame); got != "xyZq" || !reflect.DeepEqual(s.frame, s.scratch) {
215+ t.Fatalf("adjacent spans replay = %q, frames equal=%v", got, reflect.DeepEqual(s.frame, s.scratch))
216+ }
217+}
218+
219+func TestProxyScreenStateDamageGenerationAndCapture(t *testing.T) {
220+ s := newProxyScreenState(domain.Size{Cols: 3, Rows: 2})
221+ if err := s.Apply(screenSnapshot(3, 2, " ", " ")); err != nil {
222+ t.Fatal(err)
223+ }
224+ first := s.CaptureDamage()
225+ if len(first.Damage) != 1 || first.Damage[0].Kind != renderer.DamageFullRedraw {
226+ t.Fatalf("initial damage = %+v", first.Damage)
227+ }
228+ if !s.AcknowledgeDamage(first.Generation) {
229+ t.Fatal("exact initial acknowledgement failed")
230+ }
231+ if len(s.CaptureDamage().Damage) != 0 {
232+ t.Fatal("exact acknowledgement left damage")
233+ }
234+ applyProxyDelta(t, s, ports.ScreenUpdate{
235+ Size: domain.Size{Cols: 3, Rows: 2},
236+ Spans: []ports.ScreenSpan{{Y: 1, Cells: cells("xyz")}},
237+ })
238+ captured := s.CaptureDamage()
239+ if len(captured.Damage) != 1 || captured.Damage[0].Width != 3 {
240+ t.Fatalf("span damage = %+v", captured.Damage)
241+ }
242+ if s.AcknowledgeDamage(captured.Generation - 1) {
243+ t.Fatal("stale acknowledgement succeeded")
244+ }
245+ forced := s.CaptureDamage()
246+ if len(forced.Damage) != 1 || forced.Damage[0].Kind != renderer.DamageFullRedraw {
247+ t.Fatalf("stale acknowledgement damage = %+v", forced.Damage)
248+ }
249+ if !s.AcknowledgeDamage(forced.Generation) || len(s.CaptureDamage().Damage) != 0 {
250+ t.Fatal("full redraw did not clear on exact acknowledgement")
251+ }
252+}
253+
254+func TestProxyScreenStateCaptureAndResize(t *testing.T) {
255+ s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2})
256+ if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil {
257+ t.Fatal(err)
258+ }
259+ _ = s.AcknowledgeDamage(s.CaptureDamage().Generation)
260+ var dst renderer.Frame
261+ s.CaptureInto(&dst)
262+ if got := proxyFrameText(dst); got != "abcdefgh" {
263+ t.Fatalf("captured frame = %q", got)
264+ }
265+ applyProxyDelta(t, s, ports.ScreenUpdate{
266+ Size: domain.Size{Cols: 4, Rows: 2},
267+ Spans: []ports.ScreenSpan{{Y: 0, X: 1, Cells: cells("Z")}},
268+ })
269+ s.CaptureInto(&dst)
270+ if got := proxyFrameText(dst); got != "aZcdefgh" {
271+ t.Fatalf("incrementally captured frame = %q", got)
272+ }
273+ if !s.ResizePlaceholder(domain.Size{Cols: 3, Rows: 3}) {
274+ t.Fatal("resize placeholder rejected valid size")
275+ }
276+ if got := proxyFrameText(s.frame); got != "aZcefg " {
277+ t.Fatalf("overlap-preserving resize = %q", got)
278+ }
279+ capture := s.CaptureDamage()
280+ if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw {
281+ t.Fatalf("resize damage = %+v", capture.Damage)
282+ }
283+}
284+
285+func TestProxyScreenStateCaptureFallsBackAfterSpanThenScroll(t *testing.T) {
286+ s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2})
287+ if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil {
288+ t.Fatal(err)
289+ }
290+ _ = s.AcknowledgeDamage(s.CaptureDamage().Generation)
291+ dst := s.frame.Clone()
292+ applyProxyDelta(t, s, ports.ScreenUpdate{
293+ Size: domain.Size{Cols: 4, Rows: 2},
294+ Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("Z")}},
295+ })
296+ applyProxyDelta(t, s, ports.ScreenUpdate{
297+ Size: domain.Size{Cols: 4, Rows: 2},
298+ Scroll: &ports.ScreenScroll{Top: 0, Height: 2, Count: 1},
299+ Spans: []ports.ScreenSpan{{Y: 1, Cells: cells("IJKL")}},
300+ })
301+ capture := s.CaptureDamage()
302+ if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw {
303+ t.Fatalf("span-then-scroll damage = %+v", capture.Damage)
304+ }
305+ s.CaptureInto(&dst)
306+ if !reflect.DeepEqual(dst, s.frame.Clone()) {
307+ t.Fatal("span-then-scroll capture diverged from live frame")
308+ }
309+}
310+
311+func TestProxyScreenStateCaptureFallsBackAfterRepeatedScroll(t *testing.T) {
312+ s := newProxyScreenState(domain.Size{Cols: 4, Rows: 3})
313+ if err := s.Apply(screenSnapshot(4, 3, "abcd", "efgh", "ijkl")); err != nil {
314+ t.Fatal(err)
315+ }
316+ _ = s.AcknowledgeDamage(s.CaptureDamage().Generation)
317+ dst := s.frame.Clone()
318+ scroll := func(line string) {
319+ t.Helper()
320+ applyProxyDelta(t, s, ports.ScreenUpdate{
321+ Size: domain.Size{Cols: 4, Rows: 3},
322+ Scroll: &ports.ScreenScroll{Top: 0, Height: 3, Count: 1},
323+ Spans: []ports.ScreenSpan{{Y: 2, Cells: cells(line)}},
324+ })
325+ }
326+ scroll("WXYZ")
327+ if len(s.CaptureDamage().Damage) != 2 {
328+ t.Fatalf("first scroll damage = %+v", s.CaptureDamage().Damage)
329+ }
330+ scroll("QRST")
331+ capture := s.CaptureDamage()
332+ if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw {
333+ t.Fatalf("repeated-scroll damage = %+v", capture.Damage)
334+ }
335+ s.CaptureInto(&dst)
336+ if !reflect.DeepEqual(dst, s.frame.Clone()) {
337+ t.Fatal("repeated-scroll capture diverged from live frame")
338+ }
339+}
340+
341+func TestProxyScreenStateCursorOnlyAndDamageBound(t *testing.T) {
342+ s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2})
343+ if err := s.Apply(screenSnapshot(2, 2, " ", " ")); err != nil {
344+ t.Fatal(err)
345+ }
346+ _ = s.AcknowledgeDamage(s.generation)
347+ applyProxyDelta(t, s, ports.ScreenUpdate{
348+ Size: domain.Size{Cols: 2, Rows: 2},
349+ Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true},
350+ })
351+ if s.cursorOut.Row != 1 || s.cursorOut.Col != 1 || len(s.damage) != 0 {
352+ t.Fatalf("cursor-only update = cursor=%+v damage=%+v", s.cursorOut, s.damage)
353+ }
354+ for i := 0; i < maxProxyScreenDamage; i++ {
355+ applyProxyDelta(t, s, ports.ScreenUpdate{
356+ Size: domain.Size{Cols: 2, Rows: 2},
357+ Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("x")}},
358+ })
359+ }
360+ if len(s.damage) != maxProxyScreenDamage {
361+ t.Fatalf("damage length = %d, want %d", len(s.damage), maxProxyScreenDamage)
362+ }
363+ applyProxyDelta(t, s, ports.ScreenUpdate{
364+ Size: domain.Size{Cols: 2, Rows: 2},
365+ Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("y")}},
366+ })
367+ if len(s.damage) != 1 || s.damage[0].Kind != renderer.DamageFullRedraw {
368+ t.Fatalf("saturated damage = %+v", s.damage)
369+ }
370+}
371+
372+func BenchmarkProxyScreenStateApply(b *testing.B) {
373+ size := domain.Size{Cols: 120, Rows: 40}
374+ cases := []struct {
375+ name string
376+ update ports.ScreenUpdate
377+ cells, spans int
378+ }{
379+ {name: "one-cell", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 1, X: 0, Cells: cells("x")}}}, cells: 1, spans: 1},
380+ {name: "full-line", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 2, Cells: cellsRepeat('x', 120)}}}, cells: 120, spans: 1},
381+ {name: "styled-fragments", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 3, Cells: styledCells(120)}}}, cells: 120, spans: 1},
382+ {name: "full-screen-scroll", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Scroll: &ports.ScreenScroll{Top: 0, Height: 40, Count: 1}, Spans: []ports.ScreenSpan{{Y: 39, Cells: cellsRepeat('x', 120)}}}, cells: 120 * 40, spans: 2},
383+ }
384+ for _, tc := range cases {
385+ b.Run(tc.name, func(b *testing.B) {
386+ s := newProxyScreenState(size)
387+ if err := s.Apply(blankSnapshot(size)); err != nil {
388+ b.Fatal(err)
389+ }
390+ _ = s.AcknowledgeDamage(s.generation)
391+ // Warm the bounded damage buffer before timing the replay loop.
392+ if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil {
393+ b.Fatal(err)
394+ }
395+ _ = s.AcknowledgeDamage(s.generation)
396+ b.ResetTimer()
397+ b.ReportMetric(float64(tc.cells), "cells/op")
398+ b.ReportMetric(float64(tc.spans), "spans/op")
399+ for range b.N {
400+ if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil {
401+ b.Fatal(err)
402+ }
403+ _ = s.AcknowledgeDamage(s.generation)
404+ }
405+ })
406+ }
407+}
408+
409+func applyProxyDelta(t *testing.T, s *proxyScreenState, update ports.ScreenUpdate) {
410+ t.Helper()
411+ if err := s.Apply(nextProxyDelta(s, update)); err != nil {
412+ t.Fatal(err)
413+ }
414+}
415+
416+func nextProxyDelta(s *proxyScreenState, update ports.ScreenUpdate) ports.ScreenUpdate {
417+ update.Kind = ports.ScreenUpdateDelta
418+ update.BaseStateNum = s.stateNum
419+ update.NewStateNum = s.stateNum + 1
420+ return update
421+}
422+
423+func screenSnapshot(cols, rows int, lines ...string) ports.ScreenUpdate {
424+ spans := make([]ports.ScreenSpan, len(lines))
425+ for y, line := range lines {
426+ spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cells(line)}
427+ }
428+ return ports.ScreenUpdate{NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: domain.Size{Cols: cols, Rows: rows}, Spans: spans}
429+}
430+
431+func blankSnapshot(size domain.Size) ports.ScreenUpdate {
432+ spans := make([]ports.ScreenSpan, size.Rows)
433+ for y := range spans {
434+ spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cellsRepeat(' ', size.Cols)}
435+ }
436+ return ports.ScreenUpdate{NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: size, Spans: spans}
437+}
438+
439+func cells(text string) []renderer.Cell {
440+ out := make([]renderer.Cell, 0, len(text))
441+ for _, r := range text {
442+ out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()})
443+ }
444+ return out
445+}
446+
447+func cellsRepeat(r rune, n int) []renderer.Cell {
448+ out := make([]renderer.Cell, n)
449+ for i := range out {
450+ out[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}
451+ }
452+ return out
453+}
454+
455+func styledCells(n int) []renderer.Cell {
456+ out := cellsRepeat('s', n)
457+ for i := range out {
458+ out[i].Style.Bold = i%2 == 0
459+ }
460+ return out
461+}
462+
463+func proxyFrameText(frame renderer.Frame) string {
464+ out := make([]rune, 0, frame.Width*frame.Height)
465+ for y := 0; y < frame.Height; y++ {
466+ for _, cell := range frame.Row(y) {
467+ out = append(out, cell.Rune)
468+ }
469+ }
470+ return string(out)
471+}

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