feat(protocol): add structured proxy screen wire
- commit
- e147aea8cfdea98d5eb979ad85209592f94369b4
- author
- brice · · 2026-08-02 05:58 UTC
- parent
- 0a7eb6a
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
11 files · +1945 -25
M internal/adapters/ipc/transport.go +3 -8
| @@ -10,11 +10,6 @@ import ( | |||
| 10 | 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 | 13 | // frameHeaderLen is the size, in bytes, of the length prefix that precedes | |
| 19 | 14 | // every frame on the wire. | |
| 20 | 15 | const frameHeaderLen = 4 | |
| @@ -24,7 +19,7 @@ const frameHeaderLen = 4 | |||
| 24 | 19 | var ErrZeroLengthFrame = errors.New("ipc: zero-length frame") | |
| 25 | 20 | ||
| 26 | 21 | // 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 | 23 | var ErrFrameTooLarge = errors.New("ipc: frame exceeds maximum length") | |
| 29 | 24 | ||
| 30 | 25 | // 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 | 140 | func marshalFrame(f ports.Frame) ([]byte, error) { | |
| 146 | 141 | n := 1 + len(f.Payload) // type + payload | |
| 147 | - | if n > maxFrameLen { | |
| 142 | + | if n > ports.MaxFrameLen { | |
| 148 | 143 | return nil, ErrFrameTooLarge | |
| 149 | 144 | } | |
| 150 | 145 | buf := make([]byte, frameHeaderLen+n) | |
| @@ -274,7 +269,7 @@ func (t *unixTransport) Recv() (ports.Frame, error) { | |||
| 274 | 269 | end(false) | |
| 275 | 270 | return ports.Frame{}, ErrZeroLengthFrame | |
| 276 | 271 | } | |
| 277 | - | if n > maxFrameLen { | |
| 272 | + | if n > ports.MaxFrameLen { | |
| 278 | 273 | end(false) | |
| 279 | 274 | return ports.Frame{}, ErrFrameTooLarge | |
| 280 | 275 | } | |
| @@ -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 precedes | 13 | // 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 = 4 | 15 | 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 exceeds | 21 | // 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 using | 25 | // 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 + payload | 141 | n := 1 + len(f.Payload) // type + payload | ||
| 147 | - | if n > maxFrameLen { | 142 | + | if n > ports.MaxFrameLen { |
| 148 | return nil, ErrFrameTooLarge | 143 | 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{}, ErrZeroLengthFrame | 270 | 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{}, ErrFrameTooLarge | 274 | return ports.Frame{}, ErrFrameTooLarge | ||
| 280 | } | 275 | } | ||
M internal/adapters/ipc/transport_test.go +4 -4
| @@ -96,8 +96,8 @@ func TestTransportLargeFrameNearCap(t *testing.T) { | |||
| 96 | 96 | server := NewTransport(c2) | |
| 97 | 97 | ||
| 98 | 98 | // 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) | |
| 101 | 101 | for i := range payload { | |
| 102 | 102 | payload[i] = byte(i) | |
| 103 | 103 | } | |
| @@ -177,7 +177,7 @@ func TestTransportRecvOversizeFrame(t *testing.T) { | |||
| 177 | 177 | ||
| 178 | 178 | go func() { | |
| 179 | 179 | var hdr [4]byte | |
| 180 | - | binary.BigEndian.PutUint32(hdr[:], maxFrameLen+1) | |
| 180 | + | binary.BigEndian.PutUint32(hdr[:], ports.MaxFrameLen+1) | |
| 181 | 181 | _, _ = c1.Write(hdr[:]) | |
| 182 | 182 | }() | |
| 183 | 183 | ||
| @@ -227,7 +227,7 @@ func TestTransportSendOversizePayloadRejected(t *testing.T) { | |||
| 227 | 227 | defer func() { _ = c2.Close() }() | |
| 228 | 228 | ||
| 229 | 229 | client := NewTransport(c1) | |
| 230 | - | payload := make([]byte, maxFrameLen) // +1 byte type => exceeds max | |
| 230 | + | payload := make([]byte, ports.MaxFrameLen) // +1 byte type => exceeds max | |
| 231 | 231 | err := client.Send(ports.Frame{Type: ports.MsgOutput, Payload: payload}) | |
| 232 | 232 | if !errors.Is(err, ErrFrameTooLarge) { | |
| 233 | 233 | 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 fits | 98 | // 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]byte | 179 | 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 max | 230 | + | 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 ( | |||
| 21 | 21 | ) | |
| 22 | 22 | ||
| 23 | 23 | const ( | |
| 24 | - | maxFrameLen = 16 << 20 | |
| 25 | 24 | frameHeaderLen = 4 | |
| 26 | 25 | sshCloseTimeout = 3 * time.Second | |
| 27 | 26 | ) | |
| @@ -78,7 +77,7 @@ type transport struct { | |||
| 78 | 77 | func (t *transport) Send(f ports.Frame) error { | |
| 79 | 78 | end := t.beginOperation(ports.RuntimeAdapterSendStart, uint64(len(f.Payload))) | |
| 80 | 79 | n := 1 + len(f.Payload) | |
| 81 | - | if n > maxFrameLen { | |
| 80 | + | if n > ports.MaxFrameLen { | |
| 82 | 81 | end(false) | |
| 83 | 82 | return ErrFrameTooLarge | |
| 84 | 83 | } | |
| @@ -109,7 +108,7 @@ func (t *transport) Recv() (ports.Frame, error) { | |||
| 109 | 108 | end(false) | |
| 110 | 109 | return ports.Frame{}, ErrZeroLengthFrame | |
| 111 | 110 | } | |
| 112 | - | if n > maxFrameLen { | |
| 111 | + | if n > ports.MaxFrameLen { | |
| 113 | 112 | end(false) | |
| 114 | 113 | return ports.Frame{}, ErrFrameTooLarge | |
| 115 | 114 | } | |
| @@ -21,7 +21,6 @@ import ( | |||||
| 21 | ) | 21 | ) | ||
| 22 | 22 | ||||
| 23 | const ( | 23 | const ( | ||
| 24 | - | maxFrameLen = 16 << 20 | |||
| 25 | frameHeaderLen = 4 | 24 | frameHeaderLen = 4 | ||
| 26 | sshCloseTimeout = 3 * time.Second | 25 | 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 ErrFrameTooLarge | 82 | 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{}, ErrZeroLengthFrame | 109 | 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{}, ErrFrameTooLarge | 113 | return ports.Frame{}, ErrFrameTooLarge | ||
| 115 | } | 114 | } | ||
M internal/adapters/sshstdio/transport_test.go +17 -0
| @@ -3,6 +3,7 @@ package sshstdio | |||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | 5 | "context" | |
| 6 | + | "encoding/binary" | |
| 6 | 7 | "errors" | |
| 7 | 8 | "io" | |
| 8 | 9 | "log/slog" | |
| @@ -164,6 +165,22 @@ func TestTransportRejectsZeroLengthFrame(t *testing.T) { | |||
| 164 | 165 | } | |
| 165 | 166 | } | |
| 166 | 167 | ||
| 168 | + | func TestTransportRejectsFramesOverSharedMaximum(t *testing.T) { | |
| 169 | + | send := NewTransport(nil, io.Discard, nil) | |
| 170 | + | err := send.Send(ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, ports.MaxFrameLen)}) | |
| 171 | + | if !errors.Is(err, ErrFrameTooLarge) { | |
| 172 | + | t.Fatalf("Send error = %v, want ErrFrameTooLarge", err) | |
| 173 | + | } | |
| 174 | + | ||
| 175 | + | var header [frameHeaderLen]byte | |
| 176 | + | binary.BigEndian.PutUint32(header[:], ports.MaxFrameLen+1) | |
| 177 | + | recv := NewTransport(bytes.NewReader(header[:]), io.Discard, nil) | |
| 178 | + | _, err = recv.Recv() | |
| 179 | + | if !errors.Is(err, ErrFrameTooLarge) { | |
| 180 | + | t.Fatalf("Recv error = %v, want ErrFrameTooLarge", err) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | ||
| 167 | 184 | func TestDialContextCanceledBeforeStart(t *testing.T) { | |
| 168 | 185 | ctx, cancel := context.WithCancel(context.Background()) | |
| 169 | 186 | 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,22 @@ func TestTransportRejectsZeroLengthFrame(t *testing.T) { | |||||
| 164 | } | 165 | } | ||
| 165 | } | 166 | } | ||
| 166 | 167 | ||||
| 168 | + | func TestTransportRejectsFramesOverSharedMaximum(t *testing.T) { | |||
| 169 | + | send := NewTransport(nil, io.Discard, nil) | |||
| 170 | + | err := send.Send(ports.Frame{Type: ports.MsgOutput, Payload: make([]byte, ports.MaxFrameLen)}) | |||
| 171 | + | if !errors.Is(err, ErrFrameTooLarge) { | |||
| 172 | + | t.Fatalf("Send error = %v, want ErrFrameTooLarge", err) | |||
| 173 | + | } | |||
| 174 | + | ||||
| 175 | + | var header [frameHeaderLen]byte | |||
| 176 | + | binary.BigEndian.PutUint32(header[:], ports.MaxFrameLen+1) | |||
| 177 | + | recv := NewTransport(bytes.NewReader(header[:]), io.Discard, nil) | |||
| 178 | + | _, err = recv.Recv() | |||
| 179 | + | if !errors.Is(err, ErrFrameTooLarge) { | |||
| 180 | + | t.Fatalf("Recv error = %v, want ErrFrameTooLarge", err) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | ||||
| 167 | func TestDialContextCanceledBeforeStart(t *testing.T) { | 184 | func TestDialContextCanceledBeforeStart(t *testing.T) { | ||
| 168 | ctx, cancel := context.WithCancel(context.Background()) | 185 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 169 | cancel() | 186 | cancel() | ||
M internal/ports/frame.go +6 -1
| @@ -1,7 +1,11 @@ | |||
| 1 | 1 | package ports | |
| 2 | 2 | ||
| 3 | 3 | // 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 | |
| 5 | 9 | ||
| 6 | 10 | // MsgType identifies the kind of payload carried by a Frame. | |
| 7 | 11 | type MsgType uint8 | |
| @@ -32,6 +36,7 @@ const ( | |||
| 32 | 36 | MsgSessions MsgType = 21 | |
| 33 | 37 | MsgCommandResult MsgType = 22 | |
| 34 | 38 | MsgSessionMeta MsgType = 23 | |
| 39 | + | MsgScreenUpdate MsgType = 24 | |
| 35 | 40 | ) | |
| 36 | 41 | ||
| 37 | 42 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | |
| @@ -1,7 +1,11 @@ | |||||
| 1 | package ports | 1 | 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 = 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 | |||
| 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 uint8 | 11 | type MsgType uint8 | ||
| @@ -32,6 +36,7 @@ const ( | |||||
| 32 | MsgSessions MsgType = 21 | 36 | MsgSessions MsgType = 21 | ||
| 33 | MsgCommandResult MsgType = 22 | 37 | MsgCommandResult MsgType = 22 | ||
| 34 | MsgSessionMeta MsgType = 23 | 38 | MsgSessionMeta MsgType = 23 | ||
| 39 | + | MsgScreenUpdate MsgType = 24 | |||
| 35 | ) | 40 | ) | ||
| 36 | 41 | ||||
| 37 | // Frame is the unit of exchange over a Transport: a typed, length-delimited | 42 | // 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 | |||
| 3 | 3 | import "testing" | |
| 4 | 4 | ||
| 5 | 5 | 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) | |
| 8 | 17 | } | |
| 9 | 18 | } | |
| 10 | 19 | ||
| @@ -52,6 +61,7 @@ func TestMsgTypeUnique(t *testing.T) { | |||
| 52 | 61 | {"MsgSessions", MsgSessions}, | |
| 53 | 62 | {"MsgCommandResult", MsgCommandResult}, | |
| 54 | 63 | {"MsgSessionMeta", MsgSessionMeta}, | |
| 64 | + | {"MsgScreenUpdate", MsgScreenUpdate}, | |
| 55 | 65 | } | |
| 56 | 66 | ||
| 57 | 67 | 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 ( | |||
| 10 | 10 | "encoding/binary" | |
| 11 | 11 | "errors" | |
| 12 | 12 | "math" | |
| 13 | + | "unicode/utf8" | |
| 13 | 14 | ||
| 14 | 15 | "github.com/bnema/vev/internal/domain" | |
| 15 | 16 | "github.com/bnema/vev/pkg/renderer" | |
| @@ -1075,3 +1076,755 @@ func UnmarshalSessions(b []byte) (Sessions, error) { | |||
| 1075 | 1076 | } | |
| 1076 | 1077 | 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 | + | } | |
| @@ -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}, nil | 1077 | 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) { | |||
| 122 | 122 | Env: []string{"A=B", "XY=123"}, | |
| 123 | 123 | }) | |
| 124 | 124 | want := []byte{ | |
| 125 | - | 0x00, 0x14, 0x00, 0x00, // version, intent, proxied | |
| 125 | + | 0x00, 0x15, 0x00, 0x00, // version, intent, proxied | |
| 126 | 126 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 127 | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | |
| 128 | 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | |
| @@ -336,7 +336,7 @@ func TestThemeGenerationClearedWireGoldenPreservesProtocolVersion(t *testing.T) | |||
| 336 | 336 | } | |
| 337 | 337 | want := append([]byte{0x0f, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x00, 0x00}, make([]byte, 48)...) | |
| 338 | 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 | 342 | func TestResizeGoldenAndRoundTrip(t *testing.T) { | |
| @@ -461,7 +461,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 461 | 461 | name: "minimal", | |
| 462 | 462 | msg: CommandRequest{Version: ProtocolVersion, Slug: "split-right"}, | |
| 463 | 463 | want: []byte{ | |
| 464 | - | 0x00, 0x14, | |
| 464 | + | 0x00, 0x15, | |
| 465 | 465 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 466 | 466 | 0x00, // attached | |
| 467 | 467 | 0x00, // self | |
| @@ -484,7 +484,7 @@ func TestCommandRequestGoldenAndRoundTrip(t *testing.T) { | |||
| 484 | 484 | JSON: true, | |
| 485 | 485 | }, | |
| 486 | 486 | want: []byte{ | |
| 487 | - | 0x00, 0x14, | |
| 487 | + | 0x00, 0x15, | |
| 488 | 488 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | |
| 489 | 489 | 0x00, // attached | |
| 490 | 490 | 0x01, // self | |
| @@ -914,7 +914,7 @@ func TestProxiedHelloGoldenAndStrict(t *testing.T) { | |||
| 914 | 914 | MaxOutputInFlight: 8, | |
| 915 | 915 | } | |
| 916 | 916 | want := []byte{ | |
| 917 | - | 0x00, 0x14, 0x02, 0x01, | |
| 917 | + | 0x00, 0x15, 0x02, 0x01, | |
| 918 | 918 | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x0e, 0x0f, 0x10, | |
| 919 | 919 | 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, | |
| 920 | 920 | 0x00, 0x06, 'r', 'e', 'm', 'o', 't', 'e', | |
| @@ -947,7 +947,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 947 | 947 | name: "attached", | |
| 948 | 948 | msg: CommandRequest{Version: ProtocolVersion, RequestID: 0x0102030405060708, Attached: true, Self: true, Slug: "split-right", Args: []string{"--vertical"}}, | |
| 949 | 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 | 951 | 0x00, 0x0b, 's', 'p', 'l', 'i', 't', '-', 'r', 'i', 'g', 'h', 't', | |
| 952 | 952 | 0x00, 0x01, 0x00, 0x00, 0x00, 0x0a, '-', '-', 'v', 'e', 'r', 't', 'i', 'c', 'a', 'l', | |
| 953 | 953 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| @@ -957,7 +957,7 @@ func TestCommandCorrelationGoldenAndStrict(t *testing.T) { | |||
| 957 | 957 | name: "control", | |
| 958 | 958 | msg: CommandRequest{Version: ProtocolVersion, Slug: "ls"}, | |
| 959 | 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 | 961 | 0x00, 0x02, 'l', 's', 0x00, 0x00, | |
| 962 | 962 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, | |
| 963 | 963 | }, | |
| @@ -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, proxied | 125 | + | 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 ID | 127 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // client ID | ||
| 128 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // resume token | 128 | 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 ID | 465 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 466 | 0x00, // attached | 466 | 0x00, // attached | ||
| 467 | 0x00, // self | 467 | 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 ID | 488 | 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // request ID | ||
| 489 | 0x00, // attached | 489 | 0x00, // attached | ||
| 490 | 0x01, // self | 490 | 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 +272 -0
| @@ -0,0 +1,272 @@ | |||
| 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. Safe scroll/span damage is | |
| 187 | + | // replayed incrementally; snapshots, uncertain damage, and dimensions use a | |
| 188 | + | // complete clone. | |
| 189 | + | func (s *proxyScreenState) CaptureInto(dst *renderer.Frame) { | |
| 190 | + | if s == nil || dst == nil || !validProxyFrame(*s) { | |
| 191 | + | return | |
| 192 | + | } | |
| 193 | + | if dst.Width != s.frame.Width || dst.Height != s.frame.Height || dst.Validate() != nil { | |
| 194 | + | *dst = s.frame.Clone() | |
| 195 | + | return | |
| 196 | + | } | |
| 197 | + | if len(s.damage) == 0 { | |
| 198 | + | return | |
| 199 | + | } | |
| 200 | + | for _, damage := range s.damage { | |
| 201 | + | switch damage.Kind { | |
| 202 | + | case renderer.DamageScrollUp: | |
| 203 | + | 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 { | |
| 204 | + | *dst = s.frame.Clone() | |
| 205 | + | return | |
| 206 | + | } | |
| 207 | + | dst.ScrollUp(damage.Y, damage.Y+damage.Height-1, damage.Count) | |
| 208 | + | case renderer.DamageText, renderer.DamageClear: | |
| 209 | + | 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 { | |
| 210 | + | *dst = s.frame.Clone() | |
| 211 | + | return | |
| 212 | + | } | |
| 213 | + | for y := damage.Y; y < damage.Y+damage.Height; y++ { | |
| 214 | + | copy(dst.Row(y)[damage.X:damage.X+damage.Width], s.frame.Row(y)[damage.X:damage.X+damage.Width]) | |
| 215 | + | } | |
| 216 | + | default: | |
| 217 | + | *dst = s.frame.Clone() | |
| 218 | + | return | |
| 219 | + | } | |
| 220 | + | } | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // ResizePlaceholder changes geometry while preserving the overlapping logical | |
| 224 | + | // cells. It deliberately requests a full redraw and resets the state chain. | |
| 225 | + | func (s *proxyScreenState) ResizePlaceholder(size domain.Size) bool { | |
| 226 | + | if s == nil || !validProxyScreenSize(size) { | |
| 227 | + | return false | |
| 228 | + | } | |
| 229 | + | if s.frame.Width == size.Cols && s.frame.Height == size.Rows { | |
| 230 | + | return false | |
| 231 | + | } | |
| 232 | + | next := renderer.NewFrame(size.Cols, size.Rows) | |
| 233 | + | nextScratch := renderer.NewFrame(size.Cols, size.Rows) | |
| 234 | + | copyFrameOverlap(&next, s.frame) | |
| 235 | + | copyFrameOverlap(&nextScratch, s.frame) | |
| 236 | + | s.frame, s.scratch = next, nextScratch | |
| 237 | + | if int(s.cursorOut.Row) >= size.Rows { | |
| 238 | + | s.cursorOut.Row = uint16(size.Rows - 1) | |
| 239 | + | } | |
| 240 | + | if int(s.cursorOut.Col) >= size.Cols { | |
| 241 | + | s.cursorOut.Col = uint16(size.Cols - 1) | |
| 242 | + | } | |
| 243 | + | s.stateNum = 0 | |
| 244 | + | s.generation++ | |
| 245 | + | s.setFullRedraw() | |
| 246 | + | return true | |
| 247 | + | } | |
| 248 | + | ||
| 249 | + | func copyFrameOverlap(dst *renderer.Frame, src renderer.Frame) { | |
| 250 | + | width := src.Width | |
| 251 | + | if dst.Width < width { | |
| 252 | + | width = dst.Width | |
| 253 | + | } | |
| 254 | + | height := src.Height | |
| 255 | + | if dst.Height < height { | |
| 256 | + | height = dst.Height | |
| 257 | + | } | |
| 258 | + | for y := 0; y < height; y++ { | |
| 259 | + | copy(dst.Row(y)[:width], src.Row(y)[:width]) | |
| 260 | + | } | |
| 261 | + | } | |
| 262 | + | ||
| 263 | + | func validProxyScreenSize(size domain.Size) bool { | |
| 264 | + | if size.Cols <= 0 || size.Rows <= 0 || size.Cols > math.MaxUint16 || size.Rows > math.MaxUint16 { | |
| 265 | + | return false | |
| 266 | + | } | |
| 267 | + | return size.Rows <= ports.MaxScreenCells/size.Cols | |
| 268 | + | } | |
| 269 | + | ||
| 270 | + | func validProxyFrame(s proxyScreenState) bool { | |
| 271 | + | return s.frame.Validate() == nil && s.scratch.Validate() == nil | |
| 272 | + | } | |
| @@ -0,0 +1,272 @@ | |||||
| 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. Safe scroll/span damage is | |||
| 187 | + | // replayed incrementally; snapshots, uncertain damage, and dimensions use a | |||
| 188 | + | // complete clone. | |||
| 189 | + | func (s *proxyScreenState) CaptureInto(dst *renderer.Frame) { | |||
| 190 | + | if s == nil || dst == nil || !validProxyFrame(*s) { | |||
| 191 | + | return | |||
| 192 | + | } | |||
| 193 | + | if dst.Width != s.frame.Width || dst.Height != s.frame.Height || dst.Validate() != nil { | |||
| 194 | + | *dst = s.frame.Clone() | |||
| 195 | + | return | |||
| 196 | + | } | |||
| 197 | + | if len(s.damage) == 0 { | |||
| 198 | + | return | |||
| 199 | + | } | |||
| 200 | + | for _, damage := range s.damage { | |||
| 201 | + | switch damage.Kind { | |||
| 202 | + | case renderer.DamageScrollUp: | |||
| 203 | + | 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 { | |||
| 204 | + | *dst = s.frame.Clone() | |||
| 205 | + | return | |||
| 206 | + | } | |||
| 207 | + | dst.ScrollUp(damage.Y, damage.Y+damage.Height-1, damage.Count) | |||
| 208 | + | case renderer.DamageText, renderer.DamageClear: | |||
| 209 | + | 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 { | |||
| 210 | + | *dst = s.frame.Clone() | |||
| 211 | + | return | |||
| 212 | + | } | |||
| 213 | + | for y := damage.Y; y < damage.Y+damage.Height; y++ { | |||
| 214 | + | copy(dst.Row(y)[damage.X:damage.X+damage.Width], s.frame.Row(y)[damage.X:damage.X+damage.Width]) | |||
| 215 | + | } | |||
| 216 | + | default: | |||
| 217 | + | *dst = s.frame.Clone() | |||
| 218 | + | return | |||
| 219 | + | } | |||
| 220 | + | } | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | // ResizePlaceholder changes geometry while preserving the overlapping logical | |||
| 224 | + | // cells. It deliberately requests a full redraw and resets the state chain. | |||
| 225 | + | func (s *proxyScreenState) ResizePlaceholder(size domain.Size) bool { | |||
| 226 | + | if s == nil || !validProxyScreenSize(size) { | |||
| 227 | + | return false | |||
| 228 | + | } | |||
| 229 | + | if s.frame.Width == size.Cols && s.frame.Height == size.Rows { | |||
| 230 | + | return false | |||
| 231 | + | } | |||
| 232 | + | next := renderer.NewFrame(size.Cols, size.Rows) | |||
| 233 | + | nextScratch := renderer.NewFrame(size.Cols, size.Rows) | |||
| 234 | + | copyFrameOverlap(&next, s.frame) | |||
| 235 | + | copyFrameOverlap(&nextScratch, s.frame) | |||
| 236 | + | s.frame, s.scratch = next, nextScratch | |||
| 237 | + | if int(s.cursorOut.Row) >= size.Rows { | |||
| 238 | + | s.cursorOut.Row = uint16(size.Rows - 1) | |||
| 239 | + | } | |||
| 240 | + | if int(s.cursorOut.Col) >= size.Cols { | |||
| 241 | + | s.cursorOut.Col = uint16(size.Cols - 1) | |||
| 242 | + | } | |||
| 243 | + | s.stateNum = 0 | |||
| 244 | + | s.generation++ | |||
| 245 | + | s.setFullRedraw() | |||
| 246 | + | return true | |||
| 247 | + | } | |||
| 248 | + | ||||
| 249 | + | func copyFrameOverlap(dst *renderer.Frame, src renderer.Frame) { | |||
| 250 | + | width := src.Width | |||
| 251 | + | if dst.Width < width { | |||
| 252 | + | width = dst.Width | |||
| 253 | + | } | |||
| 254 | + | height := src.Height | |||
| 255 | + | if dst.Height < height { | |||
| 256 | + | height = dst.Height | |||
| 257 | + | } | |||
| 258 | + | for y := 0; y < height; y++ { | |||
| 259 | + | copy(dst.Row(y)[:width], src.Row(y)[:width]) | |||
| 260 | + | } | |||
| 261 | + | } | |||
| 262 | + | ||||
| 263 | + | func validProxyScreenSize(size domain.Size) bool { | |||
| 264 | + | if size.Cols <= 0 || size.Rows <= 0 || size.Cols > math.MaxUint16 || size.Rows > math.MaxUint16 { | |||
| 265 | + | return false | |||
| 266 | + | } | |||
| 267 | + | return size.Rows <= ports.MaxScreenCells/size.Cols | |||
| 268 | + | } | |||
| 269 | + | ||||
| 270 | + | func validProxyFrame(s proxyScreenState) bool { | |||
| 271 | + | return s.frame.Validate() == nil && s.scratch.Validate() == nil | |||
| 272 | + | } | |||
A internal/usecase/daemon/proxy_screen_state_test.go +457 -0
| @@ -0,0 +1,457 @@ | |||
| 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: 4, Rows: 2}, | |
| 71 | + | Scroll: &ports.ScreenScroll{Top: 0, Height: 2, Count: 2}, | |
| 72 | + | Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true}, | |
| 73 | + | } | |
| 74 | + | if err := s.Apply(invalid); err == nil { | |
| 75 | + | t.Fatal("full-height scroll was accepted") | |
| 76 | + | } | |
| 77 | + | if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState { | |
| 78 | + | t.Fatal("invalid full-height scroll mutated state") | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | func TestProxyScreenStateScreenAreaLimitIsAtomic(t *testing.T) { | |
| 83 | + | boundary := newProxyScreenState(domain.Size{Cols: 512, Rows: 512}) | |
| 84 | + | if boundary.frame.Width != 512 || boundary.frame.Height != 512 || boundary.scratch.Width != 512 || boundary.scratch.Height != 512 { | |
| 85 | + | t.Fatalf("boundary state dimensions = %dx%d and %dx%d", boundary.frame.Width, boundary.frame.Height, boundary.scratch.Width, boundary.scratch.Height) | |
| 86 | + | } | |
| 87 | + | if boundary.ResizePlaceholder(domain.Size{Cols: 513, Rows: 512}) { | |
| 88 | + | t.Fatal("over-cap resize was accepted") | |
| 89 | + | } | |
| 90 | + | if boundary.frame.Width != 512 || boundary.frame.Height != 512 || boundary.scratch.Width != 512 || boundary.scratch.Height != 512 { | |
| 91 | + | t.Fatal("over-cap resize changed boundary state") | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2}) | |
| 95 | + | if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil { | |
| 96 | + | t.Fatal(err) | |
| 97 | + | } | |
| 98 | + | beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone() | |
| 99 | + | beforeCursor, beforeGeneration, beforeState, beforeDamage := s.cursorOut, s.generation, s.stateNum, s.CaptureDamage() | |
| 100 | + | if err := s.Apply(ports.ScreenUpdate{ | |
| 101 | + | NewStateNum: 2, | |
| 102 | + | Kind: ports.ScreenUpdateSnapshot, | |
| 103 | + | Size: domain.Size{Cols: 513, Rows: 512}, | |
| 104 | + | Cursor: ports.ScreenCursor{Visible: true}, | |
| 105 | + | }); err == nil { | |
| 106 | + | t.Fatal("over-cap snapshot was accepted") | |
| 107 | + | } | |
| 108 | + | 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) { | |
| 109 | + | t.Fatal("over-cap snapshot mutated state") | |
| 110 | + | } | |
| 111 | + | if got := newProxyScreenState(domain.Size{Cols: 513, Rows: 512}); got.frame.Validate() == nil || got.scratch.Validate() == nil { | |
| 112 | + | t.Fatal("over-cap constructor allocated a valid frame") | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestProxyScreenStateRejectsInvalidStateNumbersAtomically(t *testing.T) { | |
| 117 | + | s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2}) | |
| 118 | + | if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil { | |
| 119 | + | t.Fatal(err) | |
| 120 | + | } | |
| 121 | + | if got := proxyFrameText(s.frame); got != "abcd" || !reflect.DeepEqual(s.frame, s.scratch) { | |
| 122 | + | t.Fatalf("2x2 snapshot = %q, frames equal=%v", got, reflect.DeepEqual(s.frame, s.scratch)) | |
| 123 | + | } | |
| 124 | + | updates := []ports.ScreenUpdate{ | |
| 125 | + | func() ports.ScreenUpdate { | |
| 126 | + | m := screenSnapshot(2, 2, "ef", "gh") | |
| 127 | + | m.NewStateNum = 0 | |
| 128 | + | return m | |
| 129 | + | }(), | |
| 130 | + | func() ports.ScreenUpdate { | |
| 131 | + | m := screenSnapshot(2, 2, "ef", "gh") | |
| 132 | + | m.BaseStateNum, m.NewStateNum = 1, 2 | |
| 133 | + | return m | |
| 134 | + | }(), | |
| 135 | + | {Kind: ports.ScreenUpdateDelta, BaseStateNum: 0, NewStateNum: 0, Size: domain.Size{Cols: 2, Rows: 2}}, | |
| 136 | + | {Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 3, Size: domain.Size{Cols: 2, Rows: 2}}, | |
| 137 | + | {Kind: ports.ScreenUpdateDelta, BaseStateNum: 2, NewStateNum: 3, Size: domain.Size{Cols: 2, Rows: 2}}, | |
| 138 | + | } | |
| 139 | + | for i, update := range updates { | |
| 140 | + | beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone() | |
| 141 | + | beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum | |
| 142 | + | if err := s.Apply(update); err == nil { | |
| 143 | + | t.Fatalf("invalid state update %d was accepted", i) | |
| 144 | + | } | |
| 145 | + | if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState { | |
| 146 | + | t.Fatalf("invalid state update %d mutated live state", i) | |
| 147 | + | } | |
| 148 | + | } | |
| 149 | + | } | |
| 150 | + | ||
| 151 | + | func TestProxyScreenStateRejectsWireInvalidSemanticUpdatesAtomically(t *testing.T) { | |
| 152 | + | s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2}) | |
| 153 | + | if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil { | |
| 154 | + | t.Fatal(err) | |
| 155 | + | } | |
| 156 | + | assertUnchanged := func(beforeFrame, beforeScratch renderer.Frame, beforeCursor ports.ScreenCursor, beforeGeneration, beforeState uint64, beforeDamage proxyScreenDamageCapture) { | |
| 157 | + | t.Helper() | |
| 158 | + | 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) { | |
| 159 | + | t.Fatal("invalid semantic update mutated live state") | |
| 160 | + | } | |
| 161 | + | } | |
| 162 | + | ||
| 163 | + | beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone() | |
| 164 | + | beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum | |
| 165 | + | beforeDamage := s.CaptureDamage() | |
| 166 | + | invalidCursor := nextProxyDelta(s, ports.ScreenUpdate{ | |
| 167 | + | Size: domain.Size{Cols: 2, Rows: 2}, | |
| 168 | + | Cursor: ports.ScreenCursor{Style: 7, StyleSet: true}, | |
| 169 | + | }) | |
| 170 | + | if err := s.Apply(invalidCursor); err == nil { | |
| 171 | + | t.Fatal("cursor style 7 was accepted") | |
| 172 | + | } | |
| 173 | + | assertUnchanged(beforeFrame, beforeScratch, beforeCursor, beforeGeneration, beforeState, beforeDamage) | |
| 174 | + | ||
| 175 | + | beforeFrame, beforeScratch = s.frame.Clone(), s.scratch.Clone() | |
| 176 | + | beforeCursor, beforeGeneration, beforeState = s.cursorOut, s.generation, s.stateNum | |
| 177 | + | beforeDamage = s.CaptureDamage() | |
| 178 | + | invalidSpans := nextProxyDelta(s, ports.ScreenUpdate{ | |
| 179 | + | Size: domain.Size{Cols: 2, Rows: 2}, | |
| 180 | + | Spans: make([]ports.ScreenSpan, ports.MaxScreenSpans+1), | |
| 181 | + | }) | |
| 182 | + | if err := s.Apply(invalidSpans); err == nil { | |
| 183 | + | t.Fatal("too many spans were accepted") | |
| 184 | + | } | |
| 185 | + | assertUnchanged(beforeFrame, beforeScratch, beforeCursor, beforeGeneration, beforeState, beforeDamage) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | func TestProxyScreenStateAcceptsConsecutiveHorizontalSpans(t *testing.T) { | |
| 189 | + | s := newProxyScreenState(domain.Size{Cols: 4, Rows: 1}) | |
| 190 | + | if err := s.Apply(screenSnapshot(4, 1, "abcd")); err != nil { | |
| 191 | + | t.Fatal(err) | |
| 192 | + | } | |
| 193 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |
| 194 | + | Size: domain.Size{Cols: 4, Rows: 1}, | |
| 195 | + | Spans: []ports.ScreenSpan{ | |
| 196 | + | {Y: 0, X: 0, Cells: cells("xy")}, | |
| 197 | + | {Y: 0, X: 2, Cells: cells("Zq")}, | |
| 198 | + | }, | |
| 199 | + | }) | |
| 200 | + | if got := proxyFrameText(s.frame); got != "xyZq" || !reflect.DeepEqual(s.frame, s.scratch) { | |
| 201 | + | t.Fatalf("adjacent spans replay = %q, frames equal=%v", got, reflect.DeepEqual(s.frame, s.scratch)) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 205 | + | func TestProxyScreenStateDamageGenerationAndCapture(t *testing.T) { | |
| 206 | + | s := newProxyScreenState(domain.Size{Cols: 3, Rows: 2}) | |
| 207 | + | if err := s.Apply(screenSnapshot(3, 2, " ", " ")); err != nil { | |
| 208 | + | t.Fatal(err) | |
| 209 | + | } | |
| 210 | + | first := s.CaptureDamage() | |
| 211 | + | if len(first.Damage) != 1 || first.Damage[0].Kind != renderer.DamageFullRedraw { | |
| 212 | + | t.Fatalf("initial damage = %+v", first.Damage) | |
| 213 | + | } | |
| 214 | + | if !s.AcknowledgeDamage(first.Generation) { | |
| 215 | + | t.Fatal("exact initial acknowledgement failed") | |
| 216 | + | } | |
| 217 | + | if len(s.CaptureDamage().Damage) != 0 { | |
| 218 | + | t.Fatal("exact acknowledgement left damage") | |
| 219 | + | } | |
| 220 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |
| 221 | + | Size: domain.Size{Cols: 3, Rows: 2}, | |
| 222 | + | Spans: []ports.ScreenSpan{{Y: 1, Cells: cells("xyz")}}, | |
| 223 | + | }) | |
| 224 | + | captured := s.CaptureDamage() | |
| 225 | + | if len(captured.Damage) != 1 || captured.Damage[0].Width != 3 { | |
| 226 | + | t.Fatalf("span damage = %+v", captured.Damage) | |
| 227 | + | } | |
| 228 | + | if s.AcknowledgeDamage(captured.Generation - 1) { | |
| 229 | + | t.Fatal("stale acknowledgement succeeded") | |
| 230 | + | } | |
| 231 | + | forced := s.CaptureDamage() | |
| 232 | + | if len(forced.Damage) != 1 || forced.Damage[0].Kind != renderer.DamageFullRedraw { | |
| 233 | + | t.Fatalf("stale acknowledgement damage = %+v", forced.Damage) | |
| 234 | + | } | |
| 235 | + | if !s.AcknowledgeDamage(forced.Generation) || len(s.CaptureDamage().Damage) != 0 { | |
| 236 | + | t.Fatal("full redraw did not clear on exact acknowledgement") | |
| 237 | + | } | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func TestProxyScreenStateCaptureAndResize(t *testing.T) { | |
| 241 | + | s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2}) | |
| 242 | + | if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil { | |
| 243 | + | t.Fatal(err) | |
| 244 | + | } | |
| 245 | + | _ = s.AcknowledgeDamage(s.CaptureDamage().Generation) | |
| 246 | + | var dst renderer.Frame | |
| 247 | + | s.CaptureInto(&dst) | |
| 248 | + | if got := proxyFrameText(dst); got != "abcdefgh" { | |
| 249 | + | t.Fatalf("captured frame = %q", got) | |
| 250 | + | } | |
| 251 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |
| 252 | + | Size: domain.Size{Cols: 4, Rows: 2}, | |
| 253 | + | Spans: []ports.ScreenSpan{{Y: 0, X: 1, Cells: cells("Z")}}, | |
| 254 | + | }) | |
| 255 | + | s.CaptureInto(&dst) | |
| 256 | + | if got := proxyFrameText(dst); got != "aZcdefgh" { | |
| 257 | + | t.Fatalf("incrementally captured frame = %q", got) | |
| 258 | + | } | |
| 259 | + | if !s.ResizePlaceholder(domain.Size{Cols: 3, Rows: 3}) { | |
| 260 | + | t.Fatal("resize placeholder rejected valid size") | |
| 261 | + | } | |
| 262 | + | if got := proxyFrameText(s.frame); got != "aZcefg " { | |
| 263 | + | t.Fatalf("overlap-preserving resize = %q", got) | |
| 264 | + | } | |
| 265 | + | capture := s.CaptureDamage() | |
| 266 | + | if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw { | |
| 267 | + | t.Fatalf("resize damage = %+v", capture.Damage) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | func TestProxyScreenStateCaptureFallsBackAfterSpanThenScroll(t *testing.T) { | |
| 272 | + | s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2}) | |
| 273 | + | if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil { | |
| 274 | + | t.Fatal(err) | |
| 275 | + | } | |
| 276 | + | _ = s.AcknowledgeDamage(s.CaptureDamage().Generation) | |
| 277 | + | dst := s.frame.Clone() | |
| 278 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |
| 279 | + | Size: domain.Size{Cols: 4, Rows: 2}, | |
| 280 | + | Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("Z")}}, | |
| 281 | + | }) | |
| 282 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |
| 283 | + | Size: domain.Size{Cols: 4, Rows: 2}, | |
| 284 | + | Scroll: &ports.ScreenScroll{Top: 0, Height: 2, Count: 1}, | |
| 285 | + | Spans: []ports.ScreenSpan{{Y: 1, Cells: cells("IJKL")}}, | |
| 286 | + | }) | |
| 287 | + | capture := s.CaptureDamage() | |
| 288 | + | if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw { | |
| 289 | + | t.Fatalf("span-then-scroll damage = %+v", capture.Damage) | |
| 290 | + | } | |
| 291 | + | s.CaptureInto(&dst) | |
| 292 | + | if !reflect.DeepEqual(dst, s.frame.Clone()) { | |
| 293 | + | t.Fatal("span-then-scroll capture diverged from live frame") | |
| 294 | + | } | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | func TestProxyScreenStateCaptureFallsBackAfterRepeatedScroll(t *testing.T) { | |
| 298 | + | s := newProxyScreenState(domain.Size{Cols: 4, Rows: 3}) | |
| 299 | + | if err := s.Apply(screenSnapshot(4, 3, "abcd", "efgh", "ijkl")); err != nil { | |
| 300 | + | t.Fatal(err) | |
| 301 | + | } | |
| 302 | + | _ = s.AcknowledgeDamage(s.CaptureDamage().Generation) | |
| 303 | + | dst := s.frame.Clone() | |
| 304 | + | scroll := func(line string) { | |
| 305 | + | t.Helper() | |
| 306 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |
| 307 | + | Size: domain.Size{Cols: 4, Rows: 3}, | |
| 308 | + | Scroll: &ports.ScreenScroll{Top: 0, Height: 3, Count: 1}, | |
| 309 | + | Spans: []ports.ScreenSpan{{Y: 2, Cells: cells(line)}}, | |
| 310 | + | }) | |
| 311 | + | } | |
| 312 | + | scroll("WXYZ") | |
| 313 | + | if len(s.CaptureDamage().Damage) != 2 { | |
| 314 | + | t.Fatalf("first scroll damage = %+v", s.CaptureDamage().Damage) | |
| 315 | + | } | |
| 316 | + | scroll("QRST") | |
| 317 | + | capture := s.CaptureDamage() | |
| 318 | + | if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw { | |
| 319 | + | t.Fatalf("repeated-scroll damage = %+v", capture.Damage) | |
| 320 | + | } | |
| 321 | + | s.CaptureInto(&dst) | |
| 322 | + | if !reflect.DeepEqual(dst, s.frame.Clone()) { | |
| 323 | + | t.Fatal("repeated-scroll capture diverged from live frame") | |
| 324 | + | } | |
| 325 | + | } | |
| 326 | + | ||
| 327 | + | func TestProxyScreenStateCursorOnlyAndDamageBound(t *testing.T) { | |
| 328 | + | s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2}) | |
| 329 | + | if err := s.Apply(screenSnapshot(2, 2, " ", " ")); err != nil { | |
| 330 | + | t.Fatal(err) | |
| 331 | + | } | |
| 332 | + | _ = s.AcknowledgeDamage(s.generation) | |
| 333 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |
| 334 | + | Size: domain.Size{Cols: 2, Rows: 2}, | |
| 335 | + | Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true}, | |
| 336 | + | }) | |
| 337 | + | if s.cursorOut.Row != 1 || s.cursorOut.Col != 1 || len(s.damage) != 0 { | |
| 338 | + | t.Fatalf("cursor-only update = cursor=%+v damage=%+v", s.cursorOut, s.damage) | |
| 339 | + | } | |
| 340 | + | for i := 0; i < maxProxyScreenDamage; i++ { | |
| 341 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |
| 342 | + | Size: domain.Size{Cols: 2, Rows: 2}, | |
| 343 | + | Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("x")}}, | |
| 344 | + | }) | |
| 345 | + | } | |
| 346 | + | if len(s.damage) != maxProxyScreenDamage { | |
| 347 | + | t.Fatalf("damage length = %d, want %d", len(s.damage), maxProxyScreenDamage) | |
| 348 | + | } | |
| 349 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |
| 350 | + | Size: domain.Size{Cols: 2, Rows: 2}, | |
| 351 | + | Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("y")}}, | |
| 352 | + | }) | |
| 353 | + | if len(s.damage) != 1 || s.damage[0].Kind != renderer.DamageFullRedraw { | |
| 354 | + | t.Fatalf("saturated damage = %+v", s.damage) | |
| 355 | + | } | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func BenchmarkProxyScreenStateApply(b *testing.B) { | |
| 359 | + | size := domain.Size{Cols: 120, Rows: 40} | |
| 360 | + | cases := []struct { | |
| 361 | + | name string | |
| 362 | + | update ports.ScreenUpdate | |
| 363 | + | cells, spans int | |
| 364 | + | }{ | |
| 365 | + | {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}, | |
| 366 | + | {name: "full-line", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 2, Cells: cellsRepeat('x', 120)}}}, cells: 120, spans: 1}, | |
| 367 | + | {name: "styled-fragments", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 3, Cells: styledCells(120)}}}, cells: 120, spans: 1}, | |
| 368 | + | {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}, | |
| 369 | + | } | |
| 370 | + | for _, tc := range cases { | |
| 371 | + | b.Run(tc.name, func(b *testing.B) { | |
| 372 | + | s := newProxyScreenState(size) | |
| 373 | + | if err := s.Apply(blankSnapshot(size)); err != nil { | |
| 374 | + | b.Fatal(err) | |
| 375 | + | } | |
| 376 | + | _ = s.AcknowledgeDamage(s.generation) | |
| 377 | + | // Warm the bounded damage buffer before timing the replay loop. | |
| 378 | + | if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil { | |
| 379 | + | b.Fatal(err) | |
| 380 | + | } | |
| 381 | + | _ = s.AcknowledgeDamage(s.generation) | |
| 382 | + | b.ResetTimer() | |
| 383 | + | b.ReportMetric(float64(tc.cells), "cells/op") | |
| 384 | + | b.ReportMetric(float64(tc.spans), "spans/op") | |
| 385 | + | for range b.N { | |
| 386 | + | if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil { | |
| 387 | + | b.Fatal(err) | |
| 388 | + | } | |
| 389 | + | _ = s.AcknowledgeDamage(s.generation) | |
| 390 | + | } | |
| 391 | + | }) | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func applyProxyDelta(t *testing.T, s *proxyScreenState, update ports.ScreenUpdate) { | |
| 396 | + | t.Helper() | |
| 397 | + | if err := s.Apply(nextProxyDelta(s, update)); err != nil { | |
| 398 | + | t.Fatal(err) | |
| 399 | + | } | |
| 400 | + | } | |
| 401 | + | ||
| 402 | + | func nextProxyDelta(s *proxyScreenState, update ports.ScreenUpdate) ports.ScreenUpdate { | |
| 403 | + | update.Kind = ports.ScreenUpdateDelta | |
| 404 | + | update.BaseStateNum = s.stateNum | |
| 405 | + | update.NewStateNum = s.stateNum + 1 | |
| 406 | + | return update | |
| 407 | + | } | |
| 408 | + | ||
| 409 | + | func screenSnapshot(cols, rows int, lines ...string) ports.ScreenUpdate { | |
| 410 | + | spans := make([]ports.ScreenSpan, len(lines)) | |
| 411 | + | for y, line := range lines { | |
| 412 | + | spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cells(line)} | |
| 413 | + | } | |
| 414 | + | return ports.ScreenUpdate{NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: domain.Size{Cols: cols, Rows: rows}, Spans: spans} | |
| 415 | + | } | |
| 416 | + | ||
| 417 | + | func blankSnapshot(size domain.Size) ports.ScreenUpdate { | |
| 418 | + | spans := make([]ports.ScreenSpan, size.Rows) | |
| 419 | + | for y := range spans { | |
| 420 | + | spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cellsRepeat(' ', size.Cols)} | |
| 421 | + | } | |
| 422 | + | return ports.ScreenUpdate{NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: size, Spans: spans} | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | func cells(text string) []renderer.Cell { | |
| 426 | + | out := make([]renderer.Cell, 0, len(text)) | |
| 427 | + | for _, r := range text { | |
| 428 | + | out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |
| 429 | + | } | |
| 430 | + | return out | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | func cellsRepeat(r rune, n int) []renderer.Cell { | |
| 434 | + | out := make([]renderer.Cell, n) | |
| 435 | + | for i := range out { | |
| 436 | + | out[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |
| 437 | + | } | |
| 438 | + | return out | |
| 439 | + | } | |
| 440 | + | ||
| 441 | + | func styledCells(n int) []renderer.Cell { | |
| 442 | + | out := cellsRepeat('s', n) | |
| 443 | + | for i := range out { | |
| 444 | + | out[i].Style.Bold = i%2 == 0 | |
| 445 | + | } | |
| 446 | + | return out | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func proxyFrameText(frame renderer.Frame) string { | |
| 450 | + | out := make([]rune, 0, frame.Width*frame.Height) | |
| 451 | + | for y := 0; y < frame.Height; y++ { | |
| 452 | + | for _, cell := range frame.Row(y) { | |
| 453 | + | out = append(out, cell.Rune) | |
| 454 | + | } | |
| 455 | + | } | |
| 456 | + | return string(out) | |
| 457 | + | } | |
| @@ -0,0 +1,457 @@ | |||||
| 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: 4, Rows: 2}, | |||
| 71 | + | Scroll: &ports.ScreenScroll{Top: 0, Height: 2, Count: 2}, | |||
| 72 | + | Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true}, | |||
| 73 | + | } | |||
| 74 | + | if err := s.Apply(invalid); err == nil { | |||
| 75 | + | t.Fatal("full-height scroll was accepted") | |||
| 76 | + | } | |||
| 77 | + | if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState { | |||
| 78 | + | t.Fatal("invalid full-height scroll mutated state") | |||
| 79 | + | } | |||
| 80 | + | } | |||
| 81 | + | ||||
| 82 | + | func TestProxyScreenStateScreenAreaLimitIsAtomic(t *testing.T) { | |||
| 83 | + | boundary := newProxyScreenState(domain.Size{Cols: 512, Rows: 512}) | |||
| 84 | + | if boundary.frame.Width != 512 || boundary.frame.Height != 512 || boundary.scratch.Width != 512 || boundary.scratch.Height != 512 { | |||
| 85 | + | t.Fatalf("boundary state dimensions = %dx%d and %dx%d", boundary.frame.Width, boundary.frame.Height, boundary.scratch.Width, boundary.scratch.Height) | |||
| 86 | + | } | |||
| 87 | + | if boundary.ResizePlaceholder(domain.Size{Cols: 513, Rows: 512}) { | |||
| 88 | + | t.Fatal("over-cap resize was accepted") | |||
| 89 | + | } | |||
| 90 | + | if boundary.frame.Width != 512 || boundary.frame.Height != 512 || boundary.scratch.Width != 512 || boundary.scratch.Height != 512 { | |||
| 91 | + | t.Fatal("over-cap resize changed boundary state") | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2}) | |||
| 95 | + | if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil { | |||
| 96 | + | t.Fatal(err) | |||
| 97 | + | } | |||
| 98 | + | beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone() | |||
| 99 | + | beforeCursor, beforeGeneration, beforeState, beforeDamage := s.cursorOut, s.generation, s.stateNum, s.CaptureDamage() | |||
| 100 | + | if err := s.Apply(ports.ScreenUpdate{ | |||
| 101 | + | NewStateNum: 2, | |||
| 102 | + | Kind: ports.ScreenUpdateSnapshot, | |||
| 103 | + | Size: domain.Size{Cols: 513, Rows: 512}, | |||
| 104 | + | Cursor: ports.ScreenCursor{Visible: true}, | |||
| 105 | + | }); err == nil { | |||
| 106 | + | t.Fatal("over-cap snapshot was accepted") | |||
| 107 | + | } | |||
| 108 | + | 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) { | |||
| 109 | + | t.Fatal("over-cap snapshot mutated state") | |||
| 110 | + | } | |||
| 111 | + | if got := newProxyScreenState(domain.Size{Cols: 513, Rows: 512}); got.frame.Validate() == nil || got.scratch.Validate() == nil { | |||
| 112 | + | t.Fatal("over-cap constructor allocated a valid frame") | |||
| 113 | + | } | |||
| 114 | + | } | |||
| 115 | + | ||||
| 116 | + | func TestProxyScreenStateRejectsInvalidStateNumbersAtomically(t *testing.T) { | |||
| 117 | + | s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2}) | |||
| 118 | + | if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil { | |||
| 119 | + | t.Fatal(err) | |||
| 120 | + | } | |||
| 121 | + | if got := proxyFrameText(s.frame); got != "abcd" || !reflect.DeepEqual(s.frame, s.scratch) { | |||
| 122 | + | t.Fatalf("2x2 snapshot = %q, frames equal=%v", got, reflect.DeepEqual(s.frame, s.scratch)) | |||
| 123 | + | } | |||
| 124 | + | updates := []ports.ScreenUpdate{ | |||
| 125 | + | func() ports.ScreenUpdate { | |||
| 126 | + | m := screenSnapshot(2, 2, "ef", "gh") | |||
| 127 | + | m.NewStateNum = 0 | |||
| 128 | + | return m | |||
| 129 | + | }(), | |||
| 130 | + | func() ports.ScreenUpdate { | |||
| 131 | + | m := screenSnapshot(2, 2, "ef", "gh") | |||
| 132 | + | m.BaseStateNum, m.NewStateNum = 1, 2 | |||
| 133 | + | return m | |||
| 134 | + | }(), | |||
| 135 | + | {Kind: ports.ScreenUpdateDelta, BaseStateNum: 0, NewStateNum: 0, Size: domain.Size{Cols: 2, Rows: 2}}, | |||
| 136 | + | {Kind: ports.ScreenUpdateDelta, BaseStateNum: 1, NewStateNum: 3, Size: domain.Size{Cols: 2, Rows: 2}}, | |||
| 137 | + | {Kind: ports.ScreenUpdateDelta, BaseStateNum: 2, NewStateNum: 3, Size: domain.Size{Cols: 2, Rows: 2}}, | |||
| 138 | + | } | |||
| 139 | + | for i, update := range updates { | |||
| 140 | + | beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone() | |||
| 141 | + | beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum | |||
| 142 | + | if err := s.Apply(update); err == nil { | |||
| 143 | + | t.Fatalf("invalid state update %d was accepted", i) | |||
| 144 | + | } | |||
| 145 | + | if !reflect.DeepEqual(s.frame, beforeFrame) || !reflect.DeepEqual(s.scratch, beforeScratch) || s.cursorOut != beforeCursor || s.generation != beforeGeneration || s.stateNum != beforeState { | |||
| 146 | + | t.Fatalf("invalid state update %d mutated live state", i) | |||
| 147 | + | } | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | ||||
| 151 | + | func TestProxyScreenStateRejectsWireInvalidSemanticUpdatesAtomically(t *testing.T) { | |||
| 152 | + | s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2}) | |||
| 153 | + | if err := s.Apply(screenSnapshot(2, 2, "ab", "cd")); err != nil { | |||
| 154 | + | t.Fatal(err) | |||
| 155 | + | } | |||
| 156 | + | assertUnchanged := func(beforeFrame, beforeScratch renderer.Frame, beforeCursor ports.ScreenCursor, beforeGeneration, beforeState uint64, beforeDamage proxyScreenDamageCapture) { | |||
| 157 | + | t.Helper() | |||
| 158 | + | 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) { | |||
| 159 | + | t.Fatal("invalid semantic update mutated live state") | |||
| 160 | + | } | |||
| 161 | + | } | |||
| 162 | + | ||||
| 163 | + | beforeFrame, beforeScratch := s.frame.Clone(), s.scratch.Clone() | |||
| 164 | + | beforeCursor, beforeGeneration, beforeState := s.cursorOut, s.generation, s.stateNum | |||
| 165 | + | beforeDamage := s.CaptureDamage() | |||
| 166 | + | invalidCursor := nextProxyDelta(s, ports.ScreenUpdate{ | |||
| 167 | + | Size: domain.Size{Cols: 2, Rows: 2}, | |||
| 168 | + | Cursor: ports.ScreenCursor{Style: 7, StyleSet: true}, | |||
| 169 | + | }) | |||
| 170 | + | if err := s.Apply(invalidCursor); err == nil { | |||
| 171 | + | t.Fatal("cursor style 7 was accepted") | |||
| 172 | + | } | |||
| 173 | + | assertUnchanged(beforeFrame, beforeScratch, beforeCursor, beforeGeneration, beforeState, beforeDamage) | |||
| 174 | + | ||||
| 175 | + | beforeFrame, beforeScratch = s.frame.Clone(), s.scratch.Clone() | |||
| 176 | + | beforeCursor, beforeGeneration, beforeState = s.cursorOut, s.generation, s.stateNum | |||
| 177 | + | beforeDamage = s.CaptureDamage() | |||
| 178 | + | invalidSpans := nextProxyDelta(s, ports.ScreenUpdate{ | |||
| 179 | + | Size: domain.Size{Cols: 2, Rows: 2}, | |||
| 180 | + | Spans: make([]ports.ScreenSpan, ports.MaxScreenSpans+1), | |||
| 181 | + | }) | |||
| 182 | + | if err := s.Apply(invalidSpans); err == nil { | |||
| 183 | + | t.Fatal("too many spans were accepted") | |||
| 184 | + | } | |||
| 185 | + | assertUnchanged(beforeFrame, beforeScratch, beforeCursor, beforeGeneration, beforeState, beforeDamage) | |||
| 186 | + | } | |||
| 187 | + | ||||
| 188 | + | func TestProxyScreenStateAcceptsConsecutiveHorizontalSpans(t *testing.T) { | |||
| 189 | + | s := newProxyScreenState(domain.Size{Cols: 4, Rows: 1}) | |||
| 190 | + | if err := s.Apply(screenSnapshot(4, 1, "abcd")); err != nil { | |||
| 191 | + | t.Fatal(err) | |||
| 192 | + | } | |||
| 193 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 194 | + | Size: domain.Size{Cols: 4, Rows: 1}, | |||
| 195 | + | Spans: []ports.ScreenSpan{ | |||
| 196 | + | {Y: 0, X: 0, Cells: cells("xy")}, | |||
| 197 | + | {Y: 0, X: 2, Cells: cells("Zq")}, | |||
| 198 | + | }, | |||
| 199 | + | }) | |||
| 200 | + | if got := proxyFrameText(s.frame); got != "xyZq" || !reflect.DeepEqual(s.frame, s.scratch) { | |||
| 201 | + | t.Fatalf("adjacent spans replay = %q, frames equal=%v", got, reflect.DeepEqual(s.frame, s.scratch)) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 205 | + | func TestProxyScreenStateDamageGenerationAndCapture(t *testing.T) { | |||
| 206 | + | s := newProxyScreenState(domain.Size{Cols: 3, Rows: 2}) | |||
| 207 | + | if err := s.Apply(screenSnapshot(3, 2, " ", " ")); err != nil { | |||
| 208 | + | t.Fatal(err) | |||
| 209 | + | } | |||
| 210 | + | first := s.CaptureDamage() | |||
| 211 | + | if len(first.Damage) != 1 || first.Damage[0].Kind != renderer.DamageFullRedraw { | |||
| 212 | + | t.Fatalf("initial damage = %+v", first.Damage) | |||
| 213 | + | } | |||
| 214 | + | if !s.AcknowledgeDamage(first.Generation) { | |||
| 215 | + | t.Fatal("exact initial acknowledgement failed") | |||
| 216 | + | } | |||
| 217 | + | if len(s.CaptureDamage().Damage) != 0 { | |||
| 218 | + | t.Fatal("exact acknowledgement left damage") | |||
| 219 | + | } | |||
| 220 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 221 | + | Size: domain.Size{Cols: 3, Rows: 2}, | |||
| 222 | + | Spans: []ports.ScreenSpan{{Y: 1, Cells: cells("xyz")}}, | |||
| 223 | + | }) | |||
| 224 | + | captured := s.CaptureDamage() | |||
| 225 | + | if len(captured.Damage) != 1 || captured.Damage[0].Width != 3 { | |||
| 226 | + | t.Fatalf("span damage = %+v", captured.Damage) | |||
| 227 | + | } | |||
| 228 | + | if s.AcknowledgeDamage(captured.Generation - 1) { | |||
| 229 | + | t.Fatal("stale acknowledgement succeeded") | |||
| 230 | + | } | |||
| 231 | + | forced := s.CaptureDamage() | |||
| 232 | + | if len(forced.Damage) != 1 || forced.Damage[0].Kind != renderer.DamageFullRedraw { | |||
| 233 | + | t.Fatalf("stale acknowledgement damage = %+v", forced.Damage) | |||
| 234 | + | } | |||
| 235 | + | if !s.AcknowledgeDamage(forced.Generation) || len(s.CaptureDamage().Damage) != 0 { | |||
| 236 | + | t.Fatal("full redraw did not clear on exact acknowledgement") | |||
| 237 | + | } | |||
| 238 | + | } | |||
| 239 | + | ||||
| 240 | + | func TestProxyScreenStateCaptureAndResize(t *testing.T) { | |||
| 241 | + | s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2}) | |||
| 242 | + | if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil { | |||
| 243 | + | t.Fatal(err) | |||
| 244 | + | } | |||
| 245 | + | _ = s.AcknowledgeDamage(s.CaptureDamage().Generation) | |||
| 246 | + | var dst renderer.Frame | |||
| 247 | + | s.CaptureInto(&dst) | |||
| 248 | + | if got := proxyFrameText(dst); got != "abcdefgh" { | |||
| 249 | + | t.Fatalf("captured frame = %q", got) | |||
| 250 | + | } | |||
| 251 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 252 | + | Size: domain.Size{Cols: 4, Rows: 2}, | |||
| 253 | + | Spans: []ports.ScreenSpan{{Y: 0, X: 1, Cells: cells("Z")}}, | |||
| 254 | + | }) | |||
| 255 | + | s.CaptureInto(&dst) | |||
| 256 | + | if got := proxyFrameText(dst); got != "aZcdefgh" { | |||
| 257 | + | t.Fatalf("incrementally captured frame = %q", got) | |||
| 258 | + | } | |||
| 259 | + | if !s.ResizePlaceholder(domain.Size{Cols: 3, Rows: 3}) { | |||
| 260 | + | t.Fatal("resize placeholder rejected valid size") | |||
| 261 | + | } | |||
| 262 | + | if got := proxyFrameText(s.frame); got != "aZcefg " { | |||
| 263 | + | t.Fatalf("overlap-preserving resize = %q", got) | |||
| 264 | + | } | |||
| 265 | + | capture := s.CaptureDamage() | |||
| 266 | + | if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw { | |||
| 267 | + | t.Fatalf("resize damage = %+v", capture.Damage) | |||
| 268 | + | } | |||
| 269 | + | } | |||
| 270 | + | ||||
| 271 | + | func TestProxyScreenStateCaptureFallsBackAfterSpanThenScroll(t *testing.T) { | |||
| 272 | + | s := newProxyScreenState(domain.Size{Cols: 4, Rows: 2}) | |||
| 273 | + | if err := s.Apply(screenSnapshot(4, 2, "abcd", "efgh")); err != nil { | |||
| 274 | + | t.Fatal(err) | |||
| 275 | + | } | |||
| 276 | + | _ = s.AcknowledgeDamage(s.CaptureDamage().Generation) | |||
| 277 | + | dst := s.frame.Clone() | |||
| 278 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 279 | + | Size: domain.Size{Cols: 4, Rows: 2}, | |||
| 280 | + | Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("Z")}}, | |||
| 281 | + | }) | |||
| 282 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 283 | + | Size: domain.Size{Cols: 4, Rows: 2}, | |||
| 284 | + | Scroll: &ports.ScreenScroll{Top: 0, Height: 2, Count: 1}, | |||
| 285 | + | Spans: []ports.ScreenSpan{{Y: 1, Cells: cells("IJKL")}}, | |||
| 286 | + | }) | |||
| 287 | + | capture := s.CaptureDamage() | |||
| 288 | + | if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw { | |||
| 289 | + | t.Fatalf("span-then-scroll damage = %+v", capture.Damage) | |||
| 290 | + | } | |||
| 291 | + | s.CaptureInto(&dst) | |||
| 292 | + | if !reflect.DeepEqual(dst, s.frame.Clone()) { | |||
| 293 | + | t.Fatal("span-then-scroll capture diverged from live frame") | |||
| 294 | + | } | |||
| 295 | + | } | |||
| 296 | + | ||||
| 297 | + | func TestProxyScreenStateCaptureFallsBackAfterRepeatedScroll(t *testing.T) { | |||
| 298 | + | s := newProxyScreenState(domain.Size{Cols: 4, Rows: 3}) | |||
| 299 | + | if err := s.Apply(screenSnapshot(4, 3, "abcd", "efgh", "ijkl")); err != nil { | |||
| 300 | + | t.Fatal(err) | |||
| 301 | + | } | |||
| 302 | + | _ = s.AcknowledgeDamage(s.CaptureDamage().Generation) | |||
| 303 | + | dst := s.frame.Clone() | |||
| 304 | + | scroll := func(line string) { | |||
| 305 | + | t.Helper() | |||
| 306 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 307 | + | Size: domain.Size{Cols: 4, Rows: 3}, | |||
| 308 | + | Scroll: &ports.ScreenScroll{Top: 0, Height: 3, Count: 1}, | |||
| 309 | + | Spans: []ports.ScreenSpan{{Y: 2, Cells: cells(line)}}, | |||
| 310 | + | }) | |||
| 311 | + | } | |||
| 312 | + | scroll("WXYZ") | |||
| 313 | + | if len(s.CaptureDamage().Damage) != 2 { | |||
| 314 | + | t.Fatalf("first scroll damage = %+v", s.CaptureDamage().Damage) | |||
| 315 | + | } | |||
| 316 | + | scroll("QRST") | |||
| 317 | + | capture := s.CaptureDamage() | |||
| 318 | + | if len(capture.Damage) != 1 || capture.Damage[0].Kind != renderer.DamageFullRedraw { | |||
| 319 | + | t.Fatalf("repeated-scroll damage = %+v", capture.Damage) | |||
| 320 | + | } | |||
| 321 | + | s.CaptureInto(&dst) | |||
| 322 | + | if !reflect.DeepEqual(dst, s.frame.Clone()) { | |||
| 323 | + | t.Fatal("repeated-scroll capture diverged from live frame") | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | func TestProxyScreenStateCursorOnlyAndDamageBound(t *testing.T) { | |||
| 328 | + | s := newProxyScreenState(domain.Size{Cols: 2, Rows: 2}) | |||
| 329 | + | if err := s.Apply(screenSnapshot(2, 2, " ", " ")); err != nil { | |||
| 330 | + | t.Fatal(err) | |||
| 331 | + | } | |||
| 332 | + | _ = s.AcknowledgeDamage(s.generation) | |||
| 333 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 334 | + | Size: domain.Size{Cols: 2, Rows: 2}, | |||
| 335 | + | Cursor: ports.ScreenCursor{Row: 1, Col: 1, Visible: true}, | |||
| 336 | + | }) | |||
| 337 | + | if s.cursorOut.Row != 1 || s.cursorOut.Col != 1 || len(s.damage) != 0 { | |||
| 338 | + | t.Fatalf("cursor-only update = cursor=%+v damage=%+v", s.cursorOut, s.damage) | |||
| 339 | + | } | |||
| 340 | + | for i := 0; i < maxProxyScreenDamage; i++ { | |||
| 341 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 342 | + | Size: domain.Size{Cols: 2, Rows: 2}, | |||
| 343 | + | Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("x")}}, | |||
| 344 | + | }) | |||
| 345 | + | } | |||
| 346 | + | if len(s.damage) != maxProxyScreenDamage { | |||
| 347 | + | t.Fatalf("damage length = %d, want %d", len(s.damage), maxProxyScreenDamage) | |||
| 348 | + | } | |||
| 349 | + | applyProxyDelta(t, s, ports.ScreenUpdate{ | |||
| 350 | + | Size: domain.Size{Cols: 2, Rows: 2}, | |||
| 351 | + | Spans: []ports.ScreenSpan{{Y: 0, Cells: cells("y")}}, | |||
| 352 | + | }) | |||
| 353 | + | if len(s.damage) != 1 || s.damage[0].Kind != renderer.DamageFullRedraw { | |||
| 354 | + | t.Fatalf("saturated damage = %+v", s.damage) | |||
| 355 | + | } | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | func BenchmarkProxyScreenStateApply(b *testing.B) { | |||
| 359 | + | size := domain.Size{Cols: 120, Rows: 40} | |||
| 360 | + | cases := []struct { | |||
| 361 | + | name string | |||
| 362 | + | update ports.ScreenUpdate | |||
| 363 | + | cells, spans int | |||
| 364 | + | }{ | |||
| 365 | + | {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}, | |||
| 366 | + | {name: "full-line", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 2, Cells: cellsRepeat('x', 120)}}}, cells: 120, spans: 1}, | |||
| 367 | + | {name: "styled-fragments", update: ports.ScreenUpdate{Kind: ports.ScreenUpdateDelta, Size: size, Spans: []ports.ScreenSpan{{Y: 3, Cells: styledCells(120)}}}, cells: 120, spans: 1}, | |||
| 368 | + | {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}, | |||
| 369 | + | } | |||
| 370 | + | for _, tc := range cases { | |||
| 371 | + | b.Run(tc.name, func(b *testing.B) { | |||
| 372 | + | s := newProxyScreenState(size) | |||
| 373 | + | if err := s.Apply(blankSnapshot(size)); err != nil { | |||
| 374 | + | b.Fatal(err) | |||
| 375 | + | } | |||
| 376 | + | _ = s.AcknowledgeDamage(s.generation) | |||
| 377 | + | // Warm the bounded damage buffer before timing the replay loop. | |||
| 378 | + | if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil { | |||
| 379 | + | b.Fatal(err) | |||
| 380 | + | } | |||
| 381 | + | _ = s.AcknowledgeDamage(s.generation) | |||
| 382 | + | b.ResetTimer() | |||
| 383 | + | b.ReportMetric(float64(tc.cells), "cells/op") | |||
| 384 | + | b.ReportMetric(float64(tc.spans), "spans/op") | |||
| 385 | + | for range b.N { | |||
| 386 | + | if err := s.Apply(nextProxyDelta(s, tc.update)); err != nil { | |||
| 387 | + | b.Fatal(err) | |||
| 388 | + | } | |||
| 389 | + | _ = s.AcknowledgeDamage(s.generation) | |||
| 390 | + | } | |||
| 391 | + | }) | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func applyProxyDelta(t *testing.T, s *proxyScreenState, update ports.ScreenUpdate) { | |||
| 396 | + | t.Helper() | |||
| 397 | + | if err := s.Apply(nextProxyDelta(s, update)); err != nil { | |||
| 398 | + | t.Fatal(err) | |||
| 399 | + | } | |||
| 400 | + | } | |||
| 401 | + | ||||
| 402 | + | func nextProxyDelta(s *proxyScreenState, update ports.ScreenUpdate) ports.ScreenUpdate { | |||
| 403 | + | update.Kind = ports.ScreenUpdateDelta | |||
| 404 | + | update.BaseStateNum = s.stateNum | |||
| 405 | + | update.NewStateNum = s.stateNum + 1 | |||
| 406 | + | return update | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | func screenSnapshot(cols, rows int, lines ...string) ports.ScreenUpdate { | |||
| 410 | + | spans := make([]ports.ScreenSpan, len(lines)) | |||
| 411 | + | for y, line := range lines { | |||
| 412 | + | spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cells(line)} | |||
| 413 | + | } | |||
| 414 | + | return ports.ScreenUpdate{NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: domain.Size{Cols: cols, Rows: rows}, Spans: spans} | |||
| 415 | + | } | |||
| 416 | + | ||||
| 417 | + | func blankSnapshot(size domain.Size) ports.ScreenUpdate { | |||
| 418 | + | spans := make([]ports.ScreenSpan, size.Rows) | |||
| 419 | + | for y := range spans { | |||
| 420 | + | spans[y] = ports.ScreenSpan{Y: uint16(y), Cells: cellsRepeat(' ', size.Cols)} | |||
| 421 | + | } | |||
| 422 | + | return ports.ScreenUpdate{NewStateNum: 1, Kind: ports.ScreenUpdateSnapshot, Size: size, Spans: spans} | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | func cells(text string) []renderer.Cell { | |||
| 426 | + | out := make([]renderer.Cell, 0, len(text)) | |||
| 427 | + | for _, r := range text { | |||
| 428 | + | out = append(out, renderer.Cell{Rune: r, Style: renderer.DefaultStyle()}) | |||
| 429 | + | } | |||
| 430 | + | return out | |||
| 431 | + | } | |||
| 432 | + | ||||
| 433 | + | func cellsRepeat(r rune, n int) []renderer.Cell { | |||
| 434 | + | out := make([]renderer.Cell, n) | |||
| 435 | + | for i := range out { | |||
| 436 | + | out[i] = renderer.Cell{Rune: r, Style: renderer.DefaultStyle()} | |||
| 437 | + | } | |||
| 438 | + | return out | |||
| 439 | + | } | |||
| 440 | + | ||||
| 441 | + | func styledCells(n int) []renderer.Cell { | |||
| 442 | + | out := cellsRepeat('s', n) | |||
| 443 | + | for i := range out { | |||
| 444 | + | out[i].Style.Bold = i%2 == 0 | |||
| 445 | + | } | |||
| 446 | + | return out | |||
| 447 | + | } | |||
| 448 | + | ||||
| 449 | + | func proxyFrameText(frame renderer.Frame) string { | |||
| 450 | + | out := make([]rune, 0, frame.Width*frame.Height) | |||
| 451 | + | for y := 0; y < frame.Height; y++ { | |||
| 452 | + | for _, cell := range frame.Row(y) { | |||
| 453 | + | out = append(out, cell.Rune) | |||
| 454 | + | } | |||
| 455 | + | } | |||
| 456 | + | return string(out) | |||
| 457 | + | } | |||