detached d0f41bb

fix(client): correlate palette boundary response

commit
d0f41bb698b721d89b86ea8338cb3f6aaf601746
author
brice · · 2026-07-17 12:01 UTC
parent
2db8ed5
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
3 files · +109 -37
M internal/usecase/client/client.go +65 -26
@@ -8,7 +8,6 @@
88 package client
99
1010 import (
11- "bytes"
1211 "context"
1312 "crypto/rand"
1413 "errors"
@@ -602,22 +601,23 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
602601 ackQueue := newCumulativeAckQueue()
603602 sendErrCh := make(chan error, 1)
604603
605- // Palette re-queries use DSR (CSI 5 n) as an ordered sentinel. The stdin
606- // pump drains terminal replies through CSI 0 n before it permits the main
607- // loop to publish the cleared scheme and issue QueryColors. This separates
608- // bytes already queued (including Scanner.pending) from replacement replies
609- // without allowing a second goroutine to write to the terminal.
604+ // Palette re-queries use a private-mode query as an ordered boundary. The
605+ // stdin pump drains terminal replies through its mode-specific response
606+ // before it permits the main loop to publish the cleared scheme and issue
607+ // QueryColors. This separates bytes already queued (including
608+ // Scanner.pending) from replacement replies without allowing a second
609+ // goroutine to write to the terminal.
610610 colorQueryCh := make(chan colorQueryRequest)
611611 paletteReadyCh := make(chan paletteQueryReady)
612612 requestColors := func() (colorQueryRequest, bool) {
613- request := colorQueryRequest{sentinelWritten: make(chan struct{})}
613+ request := colorQueryRequest{boundaryWritten: make(chan struct{})}
614614 select {
615615 case colorQueryCh <- request:
616616 case <-loopCtx.Done():
617617 return colorQueryRequest{}, false
618618 }
619619 select {
620- case <-request.sentinelWritten:
620+ case <-request.boundaryWritten:
621621 return request, true
622622 case <-loopCtx.Done():
623623 return colorQueryRequest{}, false
@@ -669,17 +669,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
669669 return welcomedResult(errLinkOffline)
670670 }
671671 case request := <-colorQueryCh:
672- if _, err := term.Out().Write([]byte("\x1b[5n")); err != nil {
673- return welcomedResult(fmt.Errorf("vev: writing palette sentinel: %w", err))
672+ if _, err := term.Out().Write(paletteBoundaryQuery); err != nil {
673+ return welcomedResult(fmt.Errorf("vev: writing palette boundary query: %w", err))
674674 }
675675 if err := term.Flush(); err != nil {
676- return welcomedResult(fmt.Errorf("vev: flushing palette sentinel: %w", err))
676+ return welcomedResult(fmt.Errorf("vev: flushing palette boundary query: %w", err))
677677 }
678- close(request.sentinelWritten)
678+ close(request.boundaryWritten)
679679 case ready := <-paletteReadyCh:
680- // CSI 0 n proves all earlier replies have been discarded by stdin.
681- // Publish the one cleared scheme snapshot before emitting the query;
682- // stdin remains paused until QueryColors has completed below.
680+ // The private-mode response proves all earlier replies have been
681+ // discarded by stdin. Publish the one cleared scheme snapshot before
682+ // emitting the query; stdin remains paused until QueryColors has
683+ // completed below.
683684 clearedTheme := themeState.snapshot()
684685 select {
685686 case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}:
@@ -848,7 +849,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
848849 // terminal — and matches the standard pattern for stdin pumps; a
849850 // closable stdin duplicate could lift it later if ever needed.
850851 type colorQueryRequest struct {
851- sentinelWritten chan struct{}
852+ boundaryWritten chan struct{}
852853 }
853854
854855 type paletteQueryReady struct {
@@ -868,22 +869,60 @@ const (
868869 paletteAwaitingQuery
869870 )
870871
871-var paletteSentinel = []byte("\x1b[0n")
872+var paletteBoundaryQuery = []byte("\x1b[?2031$p")
873+
874+// paletteBoundaryResponses accepts valid DECRQM status values for private
875+// mode 2031. The private mode number correlates this reply with the query
876+// that starts a replacement palette generation.
877+var paletteBoundaryResponses = [][]byte{
878+ []byte("\x1b[?2031;0$y"),
879+ []byte("\x1b[?2031;1$y"),
880+ []byte("\x1b[?2031;2$y"),
881+ []byte("\x1b[?2031;3$y"),
882+ []byte("\x1b[?2031;4$y"),
883+}
872884
873-// scan consumes the DSR response while forwarding every other byte in order.
874-// It retains only a possible sentinel prefix, so a split CSI 0 n is consumed
875-// without dropping a user's partial escape sequence on cancellation.
876-func (d *paletteDrain) scan(data []byte, onBytes func([]byte), onSentinel func()) {
885+// scan consumes the private-mode boundary response while forwarding every
886+// other byte in order. It retains only a possible response prefix, so split
887+// replies are consumed without dropping a user's partial escape sequence on
888+// cancellation.
889+func (d *paletteDrain) scan(data []byte, onBytes func([]byte), onBoundary func()) {
877890 for _, b := range data {
878891 d.pending = append(d.pending, b)
879- for len(d.pending) > 0 && !bytes.HasPrefix(paletteSentinel, d.pending) {
892+ for len(d.pending) > 0 {
893+ matched := false
894+ possible := false
895+ for _, response := range paletteBoundaryResponses {
896+ if len(d.pending) > len(response) {
897+ continue
898+ }
899+ match := true
900+ for i := range d.pending {
901+ if d.pending[i] != response[i] {
902+ match = false
903+ break
904+ }
905+ }
906+ if !match {
907+ continue
908+ }
909+ if len(d.pending) == len(response) {
910+ matched = true
911+ } else {
912+ possible = true
913+ }
914+ }
915+ if matched {
916+ d.pending = nil
917+ onBoundary()
918+ break
919+ }
920+ if possible {
921+ break
922+ }
880923 onBytes(d.pending[:1])
881924 d.pending = d.pending[1:]
882925 }
883- if bytes.Equal(d.pending, paletteSentinel) {
884- d.pending = nil
885- onSentinel()
886- }
887926 }
888927 }
889928
@@ -8,7 +8,6 @@
8 package client8 package client
9 9
10 import (10 import (
11- "bytes"
12 "context"11 "context"
13 "crypto/rand"12 "crypto/rand"
14 "errors"13 "errors"
@@ -602,22 +601,23 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
602 ackQueue := newCumulativeAckQueue()601 ackQueue := newCumulativeAckQueue()
603 sendErrCh := make(chan error, 1)602 sendErrCh := make(chan error, 1)
604 603
605- // Palette re-queries use DSR (CSI 5 n) as an ordered sentinel. The stdin604+ // Palette re-queries use a private-mode query as an ordered boundary. The
606- // pump drains terminal replies through CSI 0 n before it permits the main605+ // stdin pump drains terminal replies through its mode-specific response
607- // loop to publish the cleared scheme and issue QueryColors. This separates606+ // before it permits the main loop to publish the cleared scheme and issue
608- // bytes already queued (including Scanner.pending) from replacement replies607+ // QueryColors. This separates bytes already queued (including
609- // without allowing a second goroutine to write to the terminal.608+ // Scanner.pending) from replacement replies without allowing a second
609+ // goroutine to write to the terminal.
610 colorQueryCh := make(chan colorQueryRequest)610 colorQueryCh := make(chan colorQueryRequest)
611 paletteReadyCh := make(chan paletteQueryReady)611 paletteReadyCh := make(chan paletteQueryReady)
612 requestColors := func() (colorQueryRequest, bool) {612 requestColors := func() (colorQueryRequest, bool) {
613- request := colorQueryRequest{sentinelWritten: make(chan struct{})}613+ request := colorQueryRequest{boundaryWritten: make(chan struct{})}
614 select {614 select {
615 case colorQueryCh <- request:615 case colorQueryCh <- request:
616 case <-loopCtx.Done():616 case <-loopCtx.Done():
617 return colorQueryRequest{}, false617 return colorQueryRequest{}, false
618 }618 }
619 select {619 select {
620- case <-request.sentinelWritten:620+ case <-request.boundaryWritten:
621 return request, true621 return request, true
622 case <-loopCtx.Done():622 case <-loopCtx.Done():
623 return colorQueryRequest{}, false623 return colorQueryRequest{}, false
@@ -669,17 +669,18 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
669 return welcomedResult(errLinkOffline)669 return welcomedResult(errLinkOffline)
670 }670 }
671 case request := <-colorQueryCh:671 case request := <-colorQueryCh:
672- if _, err := term.Out().Write([]byte("\x1b[5n")); err != nil {672+ if _, err := term.Out().Write(paletteBoundaryQuery); err != nil {
673- return welcomedResult(fmt.Errorf("vev: writing palette sentinel: %w", err))673+ return welcomedResult(fmt.Errorf("vev: writing palette boundary query: %w", err))
674 }674 }
675 if err := term.Flush(); err != nil {675 if err := term.Flush(); err != nil {
676- return welcomedResult(fmt.Errorf("vev: flushing palette sentinel: %w", err))676+ return welcomedResult(fmt.Errorf("vev: flushing palette boundary query: %w", err))
677 }677 }
678- close(request.sentinelWritten)678+ close(request.boundaryWritten)
679 case ready := <-paletteReadyCh:679 case ready := <-paletteReadyCh:
680- // CSI 0 n proves all earlier replies have been discarded by stdin.680+ // The private-mode response proves all earlier replies have been
681- // Publish the one cleared scheme snapshot before emitting the query;681+ // discarded by stdin. Publish the one cleared scheme snapshot before
682- // stdin remains paused until QueryColors has completed below.682+ // emitting the query; stdin remains paused until QueryColors has
683+ // completed below.
683 clearedTheme := themeState.snapshot()684 clearedTheme := themeState.snapshot()
684 select {685 select {
685 case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}:686 case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}:
@@ -848,7 +849,7 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
848 // terminal — and matches the standard pattern for stdin pumps; a849 // terminal — and matches the standard pattern for stdin pumps; a
849 // closable stdin duplicate could lift it later if ever needed.850 // closable stdin duplicate could lift it later if ever needed.
850 type colorQueryRequest struct {851 type colorQueryRequest struct {
851- sentinelWritten chan struct{}852+ boundaryWritten chan struct{}
852 }853 }
853 854
854 type paletteQueryReady struct {855 type paletteQueryReady struct {
@@ -868,22 +869,60 @@ const (
868 paletteAwaitingQuery869 paletteAwaitingQuery
869 )870 )
870 871
871-var paletteSentinel = []byte("\x1b[0n")872+var paletteBoundaryQuery = []byte("\x1b[?2031$p")
873+
874+// paletteBoundaryResponses accepts valid DECRQM status values for private
875+// mode 2031. The private mode number correlates this reply with the query
876+// that starts a replacement palette generation.
877+var paletteBoundaryResponses = [][]byte{
878+ []byte("\x1b[?2031;0$y"),
879+ []byte("\x1b[?2031;1$y"),
880+ []byte("\x1b[?2031;2$y"),
881+ []byte("\x1b[?2031;3$y"),
882+ []byte("\x1b[?2031;4$y"),
883+}
872 884
873-// scan consumes the DSR response while forwarding every other byte in order.885+// scan consumes the private-mode boundary response while forwarding every
874-// It retains only a possible sentinel prefix, so a split CSI 0 n is consumed886+// other byte in order. It retains only a possible response prefix, so split
875-// without dropping a user's partial escape sequence on cancellation.887+// replies are consumed without dropping a user's partial escape sequence on
876-func (d *paletteDrain) scan(data []byte, onBytes func([]byte), onSentinel func()) {888+// cancellation.
889+func (d *paletteDrain) scan(data []byte, onBytes func([]byte), onBoundary func()) {
877 for _, b := range data {890 for _, b := range data {
878 d.pending = append(d.pending, b)891 d.pending = append(d.pending, b)
879- for len(d.pending) > 0 && !bytes.HasPrefix(paletteSentinel, d.pending) {892+ for len(d.pending) > 0 {
893+ matched := false
894+ possible := false
895+ for _, response := range paletteBoundaryResponses {
896+ if len(d.pending) > len(response) {
897+ continue
898+ }
899+ match := true
900+ for i := range d.pending {
901+ if d.pending[i] != response[i] {
902+ match = false
903+ break
904+ }
905+ }
906+ if !match {
907+ continue
908+ }
909+ if len(d.pending) == len(response) {
910+ matched = true
911+ } else {
912+ possible = true
913+ }
914+ }
915+ if matched {
916+ d.pending = nil
917+ onBoundary()
918+ break
919+ }
920+ if possible {
921+ break
922+ }
880 onBytes(d.pending[:1])923 onBytes(d.pending[:1])
881 d.pending = d.pending[1:]924 d.pending = d.pending[1:]
882 }925 }
883- if bytes.Equal(d.pending, paletteSentinel) {
884- d.pending = nil
885- onSentinel()
886- }
887 }926 }
888 }927 }
889 928
M internal/usecase/client/client_test.go +9 -9
@@ -1048,11 +1048,11 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
10481048 require.Equal(t, int32(1), term.restoreCount.Load())
10491049 }
10501050
1051-func TestAttachSchemeRequeryUsesSentinelToDiscardSplitStalePalette(t *testing.T) {
1051+func TestAttachSchemeRequeryCorrelatesSplitBoundaryAndDiscardsStalePalette(t *testing.T) {
10521052 input := newChunkedBlockingReader(
1053- []byte("a\x1b]4;1;#112233\a\x1b[?997;2n\x1b]4;2;#44"),
1054- []byte("5566\ab\x1b["),
1055- []byte("0nc\x1b]4;3;#778899\ad"),
1053+ []byte("a\x1b]4;1;#112233\a\x1b[?997;2n"),
1054+ []byte("\x1b[0n\x1b]4;2;#445566\ab\x1b[?2031;"),
1055+ []byte("2$yc\x1b]4;3;#778899\ad"),
10561056 )
10571057 defer input.unblock()
10581058
@@ -1107,7 +1107,7 @@ func TestAttachSchemeRequeryUsesSentinelToDiscardSplitStalePalette(t *testing.T)
11071107 tr.EXPECT().Close().Return(nil).Once()
11081108
11091109 require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
1110- require.Equal(t, "\x1b[5n", out.String(), "the DSR sentinel must be the only main-loop terminal write")
1110+ require.Equal(t, "\x1b[?2031$p", out.String(), "the correlated boundary query must be the only main-loop terminal write")
11111111 require.Equal(t, int32(1), restoreCount.Load())
11121112
11131113 cleared := <-themes
@@ -1115,10 +1115,10 @@ func TestAttachSchemeRequeryUsesSentinelToDiscardSplitStalePalette(t *testing.T)
11151115 require.True(t, cleared.SchemeKnown)
11161116 require.True(t, cleared.Light)
11171117 require.Zero(t, cleared.PaletteKnown, "only the cleared scheme snapshot may cross the boundary")
1118- require.Equal(t, uint16(1<<3), fresh.PaletteKnown, "only colors after the sentinel and replacement query are accepted")
1118+ require.Equal(t, uint16(1<<3), fresh.PaletteKnown, "only colors after the boundary response and replacement query are accepted")
11191119 require.Zero(t, fresh.PaletteKnown&(1<<1|1<<2), "pre-boundary palette slots must not leak into the new generation")
11201120 inputMu.Lock()
1121- require.Equal(t, []byte("abcd"), inputBytes, "ordinary input around split stale and sentinel responses must survive")
1121+ require.Equal(t, []byte("a\x1b[0nbcd"), inputBytes, "the old status response and ordinary input around the boundary must survive")
11221122 inputMu.Unlock()
11231123 }
11241124
@@ -1129,7 +1129,7 @@ func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) {
11291129 input := newChunkedBlockingReader(
11301130 []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"),
11311131 []byte("\x1b[?997;2n"),
1132- []byte("\x1b[0n"),
1132+ []byte("\x1b[?2031;1$y"),
11331133 )
11341134 defer input.unblock()
11351135
@@ -1190,7 +1190,7 @@ func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) {
11901190 require.Len(t, themes, 2, "the old palette snapshot and one cleared scheme snapshot are sent")
11911191 require.Equal(t, uint16(1<<1|1<<14), themes[0].PaletteKnown)
11921192 require.True(t, themes[0].HasBackground)
1193- require.Zero(t, themes[1].PaletteKnown, "the DSR-delimited re-query must invalidate stale palette entries")
1193+ require.Zero(t, themes[1].PaletteKnown, "the correlated boundary re-query must invalidate stale palette entries")
11941194 require.Equal(t, themes[0].Background, themes[1].Background)
11951195 require.True(t, themes[1].SchemeKnown)
11961196 require.True(t, themes[1].Light)
@@ -1048,11 +1048,11 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
1048 require.Equal(t, int32(1), term.restoreCount.Load())1048 require.Equal(t, int32(1), term.restoreCount.Load())
1049 }1049 }
1050 1050
1051-func TestAttachSchemeRequeryUsesSentinelToDiscardSplitStalePalette(t *testing.T) {1051+func TestAttachSchemeRequeryCorrelatesSplitBoundaryAndDiscardsStalePalette(t *testing.T) {
1052 input := newChunkedBlockingReader(1052 input := newChunkedBlockingReader(
1053- []byte("a\x1b]4;1;#112233\a\x1b[?997;2n\x1b]4;2;#44"),1053+ []byte("a\x1b]4;1;#112233\a\x1b[?997;2n"),
1054- []byte("5566\ab\x1b["),1054+ []byte("\x1b[0n\x1b]4;2;#445566\ab\x1b[?2031;"),
1055- []byte("0nc\x1b]4;3;#778899\ad"),1055+ []byte("2$yc\x1b]4;3;#778899\ad"),
1056 )1056 )
1057 defer input.unblock()1057 defer input.unblock()
1058 1058
@@ -1107,7 +1107,7 @@ func TestAttachSchemeRequeryUsesSentinelToDiscardSplitStalePalette(t *testing.T)
1107 tr.EXPECT().Close().Return(nil).Once()1107 tr.EXPECT().Close().Return(nil).Once()
1108 1108
1109 require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))1109 require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
1110- require.Equal(t, "\x1b[5n", out.String(), "the DSR sentinel must be the only main-loop terminal write")1110+ require.Equal(t, "\x1b[?2031$p", out.String(), "the correlated boundary query must be the only main-loop terminal write")
1111 require.Equal(t, int32(1), restoreCount.Load())1111 require.Equal(t, int32(1), restoreCount.Load())
1112 1112
1113 cleared := <-themes1113 cleared := <-themes
@@ -1115,10 +1115,10 @@ func TestAttachSchemeRequeryUsesSentinelToDiscardSplitStalePalette(t *testing.T)
1115 require.True(t, cleared.SchemeKnown)1115 require.True(t, cleared.SchemeKnown)
1116 require.True(t, cleared.Light)1116 require.True(t, cleared.Light)
1117 require.Zero(t, cleared.PaletteKnown, "only the cleared scheme snapshot may cross the boundary")1117 require.Zero(t, cleared.PaletteKnown, "only the cleared scheme snapshot may cross the boundary")
1118- require.Equal(t, uint16(1<<3), fresh.PaletteKnown, "only colors after the sentinel and replacement query are accepted")1118+ require.Equal(t, uint16(1<<3), fresh.PaletteKnown, "only colors after the boundary response and replacement query are accepted")
1119 require.Zero(t, fresh.PaletteKnown&(1<<1|1<<2), "pre-boundary palette slots must not leak into the new generation")1119 require.Zero(t, fresh.PaletteKnown&(1<<1|1<<2), "pre-boundary palette slots must not leak into the new generation")
1120 inputMu.Lock()1120 inputMu.Lock()
1121- require.Equal(t, []byte("abcd"), inputBytes, "ordinary input around split stale and sentinel responses must survive")1121+ require.Equal(t, []byte("a\x1b[0nbcd"), inputBytes, "the old status response and ordinary input around the boundary must survive")
1122 inputMu.Unlock()1122 inputMu.Unlock()
1123 }1123 }
1124 1124
@@ -1129,7 +1129,7 @@ func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) {
1129 input := newChunkedBlockingReader(1129 input := newChunkedBlockingReader(
1130 []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"),1130 []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"),
1131 []byte("\x1b[?997;2n"),1131 []byte("\x1b[?997;2n"),
1132- []byte("\x1b[0n"),1132+ []byte("\x1b[?2031;1$y"),
1133 )1133 )
1134 defer input.unblock()1134 defer input.unblock()
1135 1135
@@ -1190,7 +1190,7 @@ func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) {
1190 require.Len(t, themes, 2, "the old palette snapshot and one cleared scheme snapshot are sent")1190 require.Len(t, themes, 2, "the old palette snapshot and one cleared scheme snapshot are sent")
1191 require.Equal(t, uint16(1<<1|1<<14), themes[0].PaletteKnown)1191 require.Equal(t, uint16(1<<1|1<<14), themes[0].PaletteKnown)
1192 require.True(t, themes[0].HasBackground)1192 require.True(t, themes[0].HasBackground)
1193- require.Zero(t, themes[1].PaletteKnown, "the DSR-delimited re-query must invalidate stale palette entries")1193+ require.Zero(t, themes[1].PaletteKnown, "the correlated boundary re-query must invalidate stale palette entries")
1194 require.Equal(t, themes[0].Background, themes[1].Background)1194 require.Equal(t, themes[0].Background, themes[1].Background)
1195 require.True(t, themes[1].SchemeKnown)1195 require.True(t, themes[1].SchemeKnown)
1196 require.True(t, themes[1].Light)1196 require.True(t, themes[1].Light)
M internal/usecase/client/theme_state_test.go +35 -2
@@ -187,7 +187,7 @@ func (r *chunksReader) Read(p []byte) (int, error) {
187187 return copy(p, chunk), nil
188188 }
189189
190-func TestStdinPumpDefersRepeatedSchemeChunksUntilSentinel(t *testing.T) {
190+func TestStdinPumpDefersRepeatedSchemeChunksUntilBoundary(t *testing.T) {
191191 ctx, cancel := context.WithCancel(context.Background())
192192 defer cancel()
193193 requests := 0
@@ -208,7 +208,40 @@ func TestStdinPumpDefersRepeatedSchemeChunksUntilSentinel(t *testing.T) {
208208 }
209209
210210 pump.run()
211- require.Equal(t, 1, requests, "a later scheme response is drained until the first sentinel arrives")
211+ require.Equal(t, 1, requests, "a later scheme response is drained until the boundary response arrives")
212+}
213+
214+func TestPaletteDrainConsumesOnlyPrivateModeBoundaryResponses(t *testing.T) {
215+ for _, status := range []byte{'0', '1', '2', '3', '4'} {
216+ t.Run(string(status), func(t *testing.T) {
217+ response := []byte("\x1b[?2031;" + string(status) + "$y")
218+ var got []byte
219+ boundaries := 0
220+ drain := paletteDrain{}
221+ drain.scan(append([]byte("x"), response[:len(response)-2]...), func(data []byte) {
222+ got = append(got, data...)
223+ }, func() { boundaries++ })
224+ drain.scan(append(response[len(response)-2:], 'y'), func(data []byte) {
225+ got = append(got, data...)
226+ }, func() { boundaries++ })
227+
228+ require.Equal(t, []byte("xy"), got)
229+ require.Equal(t, 1, boundaries)
230+ })
231+ }
232+}
233+
234+func TestPaletteDrainForwardsNonmatchingAndPartialBytes(t *testing.T) {
235+ var got []byte
236+ boundaries := 0
237+ drain := paletteDrain{}
238+ emit := func(data []byte) { got = append(got, data...) }
239+ drain.scan([]byte("\x1b[0n\x1b[?2031;5$y"), emit, func() { boundaries++ })
240+ drain.scan([]byte("\x1b[?2031;"), emit, func() { boundaries++ })
241+ drain.flush(emit)
242+
243+ require.Equal(t, []byte("\x1b[0n\x1b[?2031;5$y\x1b[?2031;"), got)
244+ require.Zero(t, boundaries)
212245 }
213246
214247 func TestStdinPumpQueryAcknowledgementCancellationDoesNotDeadlock(t *testing.T) {
@@ -187,7 +187,7 @@ func (r *chunksReader) Read(p []byte) (int, error) {
187 return copy(p, chunk), nil187 return copy(p, chunk), nil
188 }188 }
189 189
190-func TestStdinPumpDefersRepeatedSchemeChunksUntilSentinel(t *testing.T) {190+func TestStdinPumpDefersRepeatedSchemeChunksUntilBoundary(t *testing.T) {
191 ctx, cancel := context.WithCancel(context.Background())191 ctx, cancel := context.WithCancel(context.Background())
192 defer cancel()192 defer cancel()
193 requests := 0193 requests := 0
@@ -208,7 +208,40 @@ func TestStdinPumpDefersRepeatedSchemeChunksUntilSentinel(t *testing.T) {
208 }208 }
209 209
210 pump.run()210 pump.run()
211- require.Equal(t, 1, requests, "a later scheme response is drained until the first sentinel arrives")211+ require.Equal(t, 1, requests, "a later scheme response is drained until the boundary response arrives")
212+}
213+
214+func TestPaletteDrainConsumesOnlyPrivateModeBoundaryResponses(t *testing.T) {
215+ for _, status := range []byte{'0', '1', '2', '3', '4'} {
216+ t.Run(string(status), func(t *testing.T) {
217+ response := []byte("\x1b[?2031;" + string(status) + "$y")
218+ var got []byte
219+ boundaries := 0
220+ drain := paletteDrain{}
221+ drain.scan(append([]byte("x"), response[:len(response)-2]...), func(data []byte) {
222+ got = append(got, data...)
223+ }, func() { boundaries++ })
224+ drain.scan(append(response[len(response)-2:], 'y'), func(data []byte) {
225+ got = append(got, data...)
226+ }, func() { boundaries++ })
227+
228+ require.Equal(t, []byte("xy"), got)
229+ require.Equal(t, 1, boundaries)
230+ })
231+ }
232+}
233+
234+func TestPaletteDrainForwardsNonmatchingAndPartialBytes(t *testing.T) {
235+ var got []byte
236+ boundaries := 0
237+ drain := paletteDrain{}
238+ emit := func(data []byte) { got = append(got, data...) }
239+ drain.scan([]byte("\x1b[0n\x1b[?2031;5$y"), emit, func() { boundaries++ })
240+ drain.scan([]byte("\x1b[?2031;"), emit, func() { boundaries++ })
241+ drain.flush(emit)
242+
243+ require.Equal(t, []byte("\x1b[0n\x1b[?2031;5$y\x1b[?2031;"), got)
244+ require.Zero(t, boundaries)
212 }245 }
213 246
214 func TestStdinPumpQueryAcknowledgementCancellationDoesNotDeadlock(t *testing.T) {247 func TestStdinPumpQueryAcknowledgementCancellationDoesNotDeadlock(t *testing.T) {

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