detached 4634437

fix(protocol): address screen wire review findings

commit
4634437499fd206f43e13fe75c04f79e302de761
author
brice · · 2026-08-02 10:34 UTC
parent
c6e0a78
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +25 -27
M internal/ports/wire_screen.go +13 -12
@@ -66,10 +66,6 @@ const (
6666 screenStyleLen = 18
6767 screenSpanLimit = 4096
6868
69- // The frame length includes MsgScreenUpdate's type byte. The codec takes
70- // only the payload, so one byte is reserved here.
71- screenPayloadLimit = MaxFrameLen - 1
72-
7369 screenFlagCursorVisible = 1 << 0
7470 screenFlagCursorStyleSet = 1 << 1
7571 screenKnownCursorFlags = screenFlagCursorVisible | screenFlagCursorStyleSet
@@ -88,6 +84,10 @@ const (
8884 styleKnownBits = (1 << 11) - 1
8985 )
9086
87+// The frame length includes MsgScreenUpdate's type byte. The codec takes
88+// only the payload, so one byte is reserved here.
89+var screenPayloadLimit = MaxFrameLen - 1
90+
9191 // MarshalScreenUpdate encodes a canonical structured screen update.
9292 func MarshalScreenUpdate(m ScreenUpdate) ([]byte, error) {
9393 if err := validateScreenUpdate(m); err != nil {
@@ -223,7 +223,7 @@ func UnmarshalScreenUpdate(data []byte) (ScreenUpdate, error) {
223223 }
224224 m.Cursor.Visible = cursorFlags&screenFlagCursorVisible != 0
225225 m.Cursor.StyleSet = cursorFlags&screenFlagCursorStyleSet != 0
226- if !m.Cursor.StyleSet && m.Cursor.Style != 0 || m.Cursor.StyleSet && m.Cursor.Style > 6 {
226+ if (!m.Cursor.StyleSet && m.Cursor.Style != 0) || (m.Cursor.StyleSet && m.Cursor.Style > 6) {
227227 return ScreenUpdate{}, ErrInvalidScreenUpdate
228228 }
229229 scrollPresent, err := r.u8()
@@ -353,15 +353,16 @@ func UnmarshalScreenUpdate(data []byte) (ScreenUpdate, error) {
353353 }
354354
355355 func validateScreenUpdate(m ScreenUpdate) error {
356- if m.Kind == ScreenUpdateSnapshot {
356+ switch m.Kind {
357+ case ScreenUpdateSnapshot:
357358 if m.BaseStateNum != 0 || m.NewStateNum == 0 {
358359 return ErrInvalidScreenUpdate
359360 }
360- } else if m.Kind == ScreenUpdateDelta {
361+ case ScreenUpdateDelta:
361362 if m.BaseStateNum == 0 || m.BaseStateNum == math.MaxUint64 || m.NewStateNum != m.BaseStateNum+1 {
362363 return ErrInvalidScreenUpdate
363364 }
364- } else {
365+ default:
365366 return ErrInvalidScreenUpdate
366367 }
367368 if (m.Kind != ScreenUpdateSnapshot && m.Kind != ScreenUpdateDelta) ||
@@ -369,7 +370,7 @@ func validateScreenUpdate(m ScreenUpdate) error {
369370 !screenAreaWithinLimit(m.Size) || m.Cursor.Row >= uint16(m.Size.Rows) || m.Cursor.Col >= uint16(m.Size.Cols) {
370371 return ErrInvalidScreenUpdate
371372 }
372- if !m.Cursor.StyleSet && m.Cursor.Style != 0 || m.Cursor.StyleSet && m.Cursor.Style > 6 {
373+ if (!m.Cursor.StyleSet && m.Cursor.Style != 0) || (m.Cursor.StyleSet && m.Cursor.Style > 6) {
373374 return ErrInvalidScreenUpdate
374375 }
375376 if m.Kind == ScreenUpdateSnapshot && m.Scroll != nil {
@@ -405,7 +406,7 @@ func validateScreenSpans(m ScreenUpdate) error {
405406 return ErrInvalidScreenUpdate
406407 }
407408 for _, cell := range span.Cells {
408- if cell.Continuation && cell.Rune != 0 || !cell.Continuation && !utf8.ValidRune(cell.Rune) {
409+ if (cell.Continuation && cell.Rune != 0) || (!cell.Continuation && !utf8.ValidRune(cell.Rune)) {
409410 return ErrInvalidScreenUpdate
410411 }
411412 if err := validateScreenStyle(cell.Style); err != nil {
@@ -614,7 +615,7 @@ func readScreenStyle(r *screenReader) (renderer.Style, bool) {
614615 return renderer.Style{}, false
615616 }
616617 style.Foreground = -1
617- } else if foregroundIndex != math.MaxUint16 && foregroundIndex > math.MaxUint8 || fgRGB != (renderer.RGB{}) {
618+ } else if (foregroundIndex != math.MaxUint16 && foregroundIndex > math.MaxUint8) || fgRGB != (renderer.RGB{}) {
618619 return renderer.Style{}, false
619620 }
620621 if style.HasBackgroundRGB {
@@ -622,7 +623,7 @@ func readScreenStyle(r *screenReader) (renderer.Style, bool) {
622623 return renderer.Style{}, false
623624 }
624625 style.Background = -1
625- } else if backgroundIndex != math.MaxUint16 && backgroundIndex > math.MaxUint8 || bgRGB != (renderer.RGB{}) {
626+ } else if (backgroundIndex != math.MaxUint16 && backgroundIndex > math.MaxUint8) || bgRGB != (renderer.RGB{}) {
626627 return renderer.Style{}, false
627628 }
628629 if style.HasUnderlineColorRGB {
@@ -66,10 +66,6 @@ const (
66 screenStyleLen = 1866 screenStyleLen = 18
67 screenSpanLimit = 409667 screenSpanLimit = 4096
68 68
69- // The frame length includes MsgScreenUpdate's type byte. The codec takes
70- // only the payload, so one byte is reserved here.
71- screenPayloadLimit = MaxFrameLen - 1
72-
73 screenFlagCursorVisible = 1 << 069 screenFlagCursorVisible = 1 << 0
74 screenFlagCursorStyleSet = 1 << 170 screenFlagCursorStyleSet = 1 << 1
75 screenKnownCursorFlags = screenFlagCursorVisible | screenFlagCursorStyleSet71 screenKnownCursorFlags = screenFlagCursorVisible | screenFlagCursorStyleSet
@@ -88,6 +84,10 @@ const (
88 styleKnownBits = (1 << 11) - 184 styleKnownBits = (1 << 11) - 1
89 )85 )
90 86
87+// The frame length includes MsgScreenUpdate's type byte. The codec takes
88+// only the payload, so one byte is reserved here.
89+var screenPayloadLimit = MaxFrameLen - 1
90+
91 // MarshalScreenUpdate encodes a canonical structured screen update.91 // MarshalScreenUpdate encodes a canonical structured screen update.
92 func MarshalScreenUpdate(m ScreenUpdate) ([]byte, error) {92 func MarshalScreenUpdate(m ScreenUpdate) ([]byte, error) {
93 if err := validateScreenUpdate(m); err != nil {93 if err := validateScreenUpdate(m); err != nil {
@@ -223,7 +223,7 @@ func UnmarshalScreenUpdate(data []byte) (ScreenUpdate, error) {
223 }223 }
224 m.Cursor.Visible = cursorFlags&screenFlagCursorVisible != 0224 m.Cursor.Visible = cursorFlags&screenFlagCursorVisible != 0
225 m.Cursor.StyleSet = cursorFlags&screenFlagCursorStyleSet != 0225 m.Cursor.StyleSet = cursorFlags&screenFlagCursorStyleSet != 0
226- if !m.Cursor.StyleSet && m.Cursor.Style != 0 || m.Cursor.StyleSet && m.Cursor.Style > 6 {226+ if (!m.Cursor.StyleSet && m.Cursor.Style != 0) || (m.Cursor.StyleSet && m.Cursor.Style > 6) {
227 return ScreenUpdate{}, ErrInvalidScreenUpdate227 return ScreenUpdate{}, ErrInvalidScreenUpdate
228 }228 }
229 scrollPresent, err := r.u8()229 scrollPresent, err := r.u8()
@@ -353,15 +353,16 @@ func UnmarshalScreenUpdate(data []byte) (ScreenUpdate, error) {
353 }353 }
354 354
355 func validateScreenUpdate(m ScreenUpdate) error {355 func validateScreenUpdate(m ScreenUpdate) error {
356- if m.Kind == ScreenUpdateSnapshot {356+ switch m.Kind {
357+ case ScreenUpdateSnapshot:
357 if m.BaseStateNum != 0 || m.NewStateNum == 0 {358 if m.BaseStateNum != 0 || m.NewStateNum == 0 {
358 return ErrInvalidScreenUpdate359 return ErrInvalidScreenUpdate
359 }360 }
360- } else if m.Kind == ScreenUpdateDelta {361+ case ScreenUpdateDelta:
361 if m.BaseStateNum == 0 || m.BaseStateNum == math.MaxUint64 || m.NewStateNum != m.BaseStateNum+1 {362 if m.BaseStateNum == 0 || m.BaseStateNum == math.MaxUint64 || m.NewStateNum != m.BaseStateNum+1 {
362 return ErrInvalidScreenUpdate363 return ErrInvalidScreenUpdate
363 }364 }
364- } else {365+ default:
365 return ErrInvalidScreenUpdate366 return ErrInvalidScreenUpdate
366 }367 }
367 if (m.Kind != ScreenUpdateSnapshot && m.Kind != ScreenUpdateDelta) ||368 if (m.Kind != ScreenUpdateSnapshot && m.Kind != ScreenUpdateDelta) ||
@@ -369,7 +370,7 @@ func validateScreenUpdate(m ScreenUpdate) error {
369 !screenAreaWithinLimit(m.Size) || m.Cursor.Row >= uint16(m.Size.Rows) || m.Cursor.Col >= uint16(m.Size.Cols) {370 !screenAreaWithinLimit(m.Size) || m.Cursor.Row >= uint16(m.Size.Rows) || m.Cursor.Col >= uint16(m.Size.Cols) {
370 return ErrInvalidScreenUpdate371 return ErrInvalidScreenUpdate
371 }372 }
372- if !m.Cursor.StyleSet && m.Cursor.Style != 0 || m.Cursor.StyleSet && m.Cursor.Style > 6 {373+ if (!m.Cursor.StyleSet && m.Cursor.Style != 0) || (m.Cursor.StyleSet && m.Cursor.Style > 6) {
373 return ErrInvalidScreenUpdate374 return ErrInvalidScreenUpdate
374 }375 }
375 if m.Kind == ScreenUpdateSnapshot && m.Scroll != nil {376 if m.Kind == ScreenUpdateSnapshot && m.Scroll != nil {
@@ -405,7 +406,7 @@ func validateScreenSpans(m ScreenUpdate) error {
405 return ErrInvalidScreenUpdate406 return ErrInvalidScreenUpdate
406 }407 }
407 for _, cell := range span.Cells {408 for _, cell := range span.Cells {
408- if cell.Continuation && cell.Rune != 0 || !cell.Continuation && !utf8.ValidRune(cell.Rune) {409+ if (cell.Continuation && cell.Rune != 0) || (!cell.Continuation && !utf8.ValidRune(cell.Rune)) {
409 return ErrInvalidScreenUpdate410 return ErrInvalidScreenUpdate
410 }411 }
411 if err := validateScreenStyle(cell.Style); err != nil {412 if err := validateScreenStyle(cell.Style); err != nil {
@@ -614,7 +615,7 @@ func readScreenStyle(r *screenReader) (renderer.Style, bool) {
614 return renderer.Style{}, false615 return renderer.Style{}, false
615 }616 }
616 style.Foreground = -1617 style.Foreground = -1
617- } else if foregroundIndex != math.MaxUint16 && foregroundIndex > math.MaxUint8 || fgRGB != (renderer.RGB{}) {618+ } else if (foregroundIndex != math.MaxUint16 && foregroundIndex > math.MaxUint8) || fgRGB != (renderer.RGB{}) {
618 return renderer.Style{}, false619 return renderer.Style{}, false
619 }620 }
620 if style.HasBackgroundRGB {621 if style.HasBackgroundRGB {
@@ -622,7 +623,7 @@ func readScreenStyle(r *screenReader) (renderer.Style, bool) {
622 return renderer.Style{}, false623 return renderer.Style{}, false
623 }624 }
624 style.Background = -1625 style.Background = -1
625- } else if backgroundIndex != math.MaxUint16 && backgroundIndex > math.MaxUint8 || bgRGB != (renderer.RGB{}) {626+ } else if (backgroundIndex != math.MaxUint16 && backgroundIndex > math.MaxUint8) || bgRGB != (renderer.RGB{}) {
626 return renderer.Style{}, false627 return renderer.Style{}, false
627 }628 }
628 if style.HasUnderlineColorRGB {629 if style.HasUnderlineColorRGB {
M internal/ports/wire_screen_test.go +12 -15
@@ -146,10 +146,7 @@ func TestScreenUpdateStateNumbers(t *testing.T) {
146146 func TestScreenUpdateGoldens(t *testing.T) {
147147 for _, tt := range screenGoldenMessages() {
148148 t.Run(tt.name, func(t *testing.T) {
149- want, err := hex.DecodeString(tt.hex)
150- if err != nil {
151- t.Fatal(err)
152- }
149+ want := mustDecodeHex(t, tt.hex)
153150 got, err := MarshalScreenUpdate(tt.msg)
154151 if err != nil {
155152 t.Fatal(err)
@@ -188,10 +185,7 @@ func assertScreenEqual(t *testing.T, want, got ScreenUpdate) {
188185
189186 func TestScreenUpdateStrictTruncationAndTrailing(t *testing.T) {
190187 for _, tt := range screenGoldenMessages() {
191- data, err := hex.DecodeString(tt.hex)
192- if err != nil {
193- t.Fatal(err)
194- }
188+ data := mustDecodeHex(t, tt.hex)
195189 for n := 0; n < len(data); n++ {
196190 if _, err := UnmarshalScreenUpdate(data[:n]); !errors.Is(err, ErrInvalidScreenUpdate) {
197191 t.Fatalf("%s prefix %d: err = %v", tt.name, n, err)
@@ -205,7 +199,7 @@ func TestScreenUpdateStrictTruncationAndTrailing(t *testing.T) {
205199 }
206200
207201 func TestScreenUpdateHostileFields(t *testing.T) {
208- golden, _ := hex.DecodeString(screenGoldenMessages()[0].hex)
202+ golden := mustDecodeHex(t, screenGoldenMessages()[0].hex)
209203 tests := []struct {
210204 name string
211205 mut func([]byte)
@@ -289,7 +283,7 @@ func TestScreenUpdateSpanAndShapeValidation(t *testing.T) {
289283 }
290284
291285 func TestScreenUpdateOwnership(t *testing.T) {
292- golden, _ := hex.DecodeString(screenGoldenMessages()[0].hex)
286+ golden := mustDecodeHex(t, screenGoldenMessages()[0].hex)
293287 decoded, err := UnmarshalScreenUpdate(golden)
294288 if err != nil {
295289 t.Fatal(err)
@@ -309,6 +303,12 @@ func TestScreenUpdateLimits(t *testing.T) {
309303 if _, err := UnmarshalScreenUpdate(make([]byte, MaxFrameLen)); !errors.Is(err, ErrScreenUpdateTooLarge) {
310304 t.Fatalf("oversize err = %v", err)
311305 }
306+ previousLimit := screenPayloadLimit
307+ t.Cleanup(func() { screenPayloadLimit = previousLimit })
308+ screenPayloadLimit = screenHeaderLen
309+ if _, err := MarshalScreenUpdate(screenGoldenMessages()[0].msg); !errors.Is(err, ErrScreenUpdateTooLarge) {
310+ t.Fatalf("marshal oversize err = %v", err)
311+ }
312312 tooMany := screenGoldenMessages()[0].msg
313313 tooMany.Spans = make([]ScreenSpan, screenSpanLimit+1)
314314 if _, err := MarshalScreenUpdate(tooMany); !errors.Is(err, ErrInvalidScreenUpdate) {
@@ -363,7 +363,7 @@ func TestScreenUpdateScreenAreaLimit(t *testing.T) {
363363 }
364364 }
365365
366-func mustDecodeHex(t *testing.T, s string) []byte {
366+func mustDecodeHex(t testing.TB, s string) []byte {
367367 t.Helper()
368368 b, err := hex.DecodeString(s)
369369 if err != nil {
@@ -374,10 +374,7 @@ func mustDecodeHex(t *testing.T, s string) []byte {
374374
375375 func FuzzScreenUpdate(f *testing.F) {
376376 for _, tt := range screenGoldenMessages() {
377- b, err := hex.DecodeString(tt.hex)
378- if err != nil {
379- f.Fatalf("seed %s: %v", tt.name, err)
380- }
377+ b := mustDecodeHex(f, tt.hex)
381378 f.Add(b)
382379 }
383380 f.Fuzz(func(t *testing.T, data []byte) {
@@ -146,10 +146,7 @@ func TestScreenUpdateStateNumbers(t *testing.T) {
146 func TestScreenUpdateGoldens(t *testing.T) {146 func TestScreenUpdateGoldens(t *testing.T) {
147 for _, tt := range screenGoldenMessages() {147 for _, tt := range screenGoldenMessages() {
148 t.Run(tt.name, func(t *testing.T) {148 t.Run(tt.name, func(t *testing.T) {
149- want, err := hex.DecodeString(tt.hex)149+ want := mustDecodeHex(t, tt.hex)
150- if err != nil {
151- t.Fatal(err)
152- }
153 got, err := MarshalScreenUpdate(tt.msg)150 got, err := MarshalScreenUpdate(tt.msg)
154 if err != nil {151 if err != nil {
155 t.Fatal(err)152 t.Fatal(err)
@@ -188,10 +185,7 @@ func assertScreenEqual(t *testing.T, want, got ScreenUpdate) {
188 185
189 func TestScreenUpdateStrictTruncationAndTrailing(t *testing.T) {186 func TestScreenUpdateStrictTruncationAndTrailing(t *testing.T) {
190 for _, tt := range screenGoldenMessages() {187 for _, tt := range screenGoldenMessages() {
191- data, err := hex.DecodeString(tt.hex)188+ data := mustDecodeHex(t, tt.hex)
192- if err != nil {
193- t.Fatal(err)
194- }
195 for n := 0; n < len(data); n++ {189 for n := 0; n < len(data); n++ {
196 if _, err := UnmarshalScreenUpdate(data[:n]); !errors.Is(err, ErrInvalidScreenUpdate) {190 if _, err := UnmarshalScreenUpdate(data[:n]); !errors.Is(err, ErrInvalidScreenUpdate) {
197 t.Fatalf("%s prefix %d: err = %v", tt.name, n, err)191 t.Fatalf("%s prefix %d: err = %v", tt.name, n, err)
@@ -205,7 +199,7 @@ func TestScreenUpdateStrictTruncationAndTrailing(t *testing.T) {
205 }199 }
206 200
207 func TestScreenUpdateHostileFields(t *testing.T) {201 func TestScreenUpdateHostileFields(t *testing.T) {
208- golden, _ := hex.DecodeString(screenGoldenMessages()[0].hex)202+ golden := mustDecodeHex(t, screenGoldenMessages()[0].hex)
209 tests := []struct {203 tests := []struct {
210 name string204 name string
211 mut func([]byte)205 mut func([]byte)
@@ -289,7 +283,7 @@ func TestScreenUpdateSpanAndShapeValidation(t *testing.T) {
289 }283 }
290 284
291 func TestScreenUpdateOwnership(t *testing.T) {285 func TestScreenUpdateOwnership(t *testing.T) {
292- golden, _ := hex.DecodeString(screenGoldenMessages()[0].hex)286+ golden := mustDecodeHex(t, screenGoldenMessages()[0].hex)
293 decoded, err := UnmarshalScreenUpdate(golden)287 decoded, err := UnmarshalScreenUpdate(golden)
294 if err != nil {288 if err != nil {
295 t.Fatal(err)289 t.Fatal(err)
@@ -309,6 +303,12 @@ func TestScreenUpdateLimits(t *testing.T) {
309 if _, err := UnmarshalScreenUpdate(make([]byte, MaxFrameLen)); !errors.Is(err, ErrScreenUpdateTooLarge) {303 if _, err := UnmarshalScreenUpdate(make([]byte, MaxFrameLen)); !errors.Is(err, ErrScreenUpdateTooLarge) {
310 t.Fatalf("oversize err = %v", err)304 t.Fatalf("oversize err = %v", err)
311 }305 }
306+ previousLimit := screenPayloadLimit
307+ t.Cleanup(func() { screenPayloadLimit = previousLimit })
308+ screenPayloadLimit = screenHeaderLen
309+ if _, err := MarshalScreenUpdate(screenGoldenMessages()[0].msg); !errors.Is(err, ErrScreenUpdateTooLarge) {
310+ t.Fatalf("marshal oversize err = %v", err)
311+ }
312 tooMany := screenGoldenMessages()[0].msg312 tooMany := screenGoldenMessages()[0].msg
313 tooMany.Spans = make([]ScreenSpan, screenSpanLimit+1)313 tooMany.Spans = make([]ScreenSpan, screenSpanLimit+1)
314 if _, err := MarshalScreenUpdate(tooMany); !errors.Is(err, ErrInvalidScreenUpdate) {314 if _, err := MarshalScreenUpdate(tooMany); !errors.Is(err, ErrInvalidScreenUpdate) {
@@ -363,7 +363,7 @@ func TestScreenUpdateScreenAreaLimit(t *testing.T) {
363 }363 }
364 }364 }
365 365
366-func mustDecodeHex(t *testing.T, s string) []byte {366+func mustDecodeHex(t testing.TB, s string) []byte {
367 t.Helper()367 t.Helper()
368 b, err := hex.DecodeString(s)368 b, err := hex.DecodeString(s)
369 if err != nil {369 if err != nil {
@@ -374,10 +374,7 @@ func mustDecodeHex(t *testing.T, s string) []byte {
374 374
375 func FuzzScreenUpdate(f *testing.F) {375 func FuzzScreenUpdate(f *testing.F) {
376 for _, tt := range screenGoldenMessages() {376 for _, tt := range screenGoldenMessages() {
377- b, err := hex.DecodeString(tt.hex)377+ b := mustDecodeHex(f, tt.hex)
378- if err != nil {
379- f.Fatalf("seed %s: %v", tt.name, err)
380- }
381 f.Add(b)378 f.Add(b)
382 }379 }
383 f.Fuzz(func(t *testing.T, data []byte) {380 f.Fuzz(func(t *testing.T, data []byte) {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close