detached 12b1b9b

feat(client): publish definitive palette generations

commit
12b1b9bd74b5a1e0398e88344c40c4e34753204b
author
brice · · 2026-07-18 06:30 UTC
parent
588bb23
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +367 -1051
M internal/adapters/term/pty_integration_test.go +1 -1
@@ -89,7 +89,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
8989
9090 select {
9191 case got := <-captured:
92- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
92+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
9393 if string(got) != want {
9494 t.Fatalf("captured escapes = %q, want %q", got, want)
9595 }
@@ -89,7 +89,7 @@ func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) {
89 89
90 select {90 select {
91 case got := <-captured:91 case got := <-captured:
92- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit92+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
93 if string(got) != want {93 if string(got) != want {
94 t.Fatalf("captured escapes = %q, want %q", got, want)94 t.Fatalf("captured escapes = %q, want %q", got, want)
95 }95 }
M internal/adapters/term/terminal.go +1 -18
@@ -31,9 +31,6 @@ const (
3131 bracketedPasteDisable = "\x1b[?2004l"
3232 colorSchemeEnable = "\x1b[?2031h"
3333 colorSchemeDisable = "\x1b[?2031l"
34- oscDefaultColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
35- oscPaletteQuery = "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"
36- oscColorQuery = oscDefaultColorQuery + oscPaletteQuery
3734 )
3835
3936 // bufSize is the batched writer's buffer capacity.
@@ -110,7 +107,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
110107 }
111108 t.orig = old
112109
113- if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery); err != nil {
110+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil {
114111 _ = t.restoreRawLocked()
115112 return nil, fmt.Errorf("term: enter alt screen: %w", err)
116113 }
@@ -237,20 +234,6 @@ func (t *Terminal) stopResizeLocked() {
237234 t.resizeWG.Wait()
238235 }
239236
240-// QueryColors re-emits OSC 10/11 queries for default foreground/background
241-// followed by one batched OSC 4 query for palette slots 0 through 15.
242-func (t *Terminal) QueryColors() error {
243- t.mu.Lock()
244- defer t.mu.Unlock()
245- if _, err := t.bw.WriteString(oscColorQuery); err != nil {
246- return fmt.Errorf("term: query colors: %w", err)
247- }
248- if err := t.bw.Flush(); err != nil {
249- return fmt.Errorf("term: query colors: %w", err)
250- }
251- return nil
252-}
253-
254237 // In returns the reader for the controlling terminal's input.
255238 func (t *Terminal) In() io.Reader {
256239 return t.in
@@ -31,9 +31,6 @@ const (
31 bracketedPasteDisable = "\x1b[?2004l"31 bracketedPasteDisable = "\x1b[?2004l"
32 colorSchemeEnable = "\x1b[?2031h"32 colorSchemeEnable = "\x1b[?2031h"
33 colorSchemeDisable = "\x1b[?2031l"33 colorSchemeDisable = "\x1b[?2031l"
34- oscDefaultColorQuery = "\x1b]10;?\x07\x1b]11;?\x07"
35- oscPaletteQuery = "\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"
36- oscColorQuery = oscDefaultColorQuery + oscPaletteQuery
37 )34 )
38 35
39 // bufSize is the batched writer's buffer capacity.36 // bufSize is the batched writer's buffer capacity.
@@ -110,7 +107,7 @@ func (t *Terminal) EnterRaw() (func() error, error) {
110 }107 }
111 t.orig = old108 t.orig = old
112 109
113- if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + oscColorQuery); err != nil {110+ if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil {
114 _ = t.restoreRawLocked()111 _ = t.restoreRawLocked()
115 return nil, fmt.Errorf("term: enter alt screen: %w", err)112 return nil, fmt.Errorf("term: enter alt screen: %w", err)
116 }113 }
@@ -237,20 +234,6 @@ func (t *Terminal) stopResizeLocked() {
237 t.resizeWG.Wait()234 t.resizeWG.Wait()
238 }235 }
239 236
240-// QueryColors re-emits OSC 10/11 queries for default foreground/background
241-// followed by one batched OSC 4 query for palette slots 0 through 15.
242-func (t *Terminal) QueryColors() error {
243- t.mu.Lock()
244- defer t.mu.Unlock()
245- if _, err := t.bw.WriteString(oscColorQuery); err != nil {
246- return fmt.Errorf("term: query colors: %w", err)
247- }
248- if err := t.bw.Flush(); err != nil {
249- return fmt.Errorf("term: query colors: %w", err)
250- }
251- return nil
252-}
253-
254 // In returns the reader for the controlling terminal's input.237 // In returns the reader for the controlling terminal's input.
255 func (t *Terminal) In() io.Reader {238 func (t *Terminal) In() io.Reader {
256 return t.in239 return t.in
M internal/adapters/term/terminal_test.go +7 -29
@@ -10,8 +10,6 @@ import (
1010 "github.com/bnema/vev/internal/domain"
1111 )
1212
13-const wantColorQueries = "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"
14-
1513 // pipePair returns an os.Pipe and a goroutine that continuously copies
1614 // everything written to the write end into captured, closing done once
1715 // the write end is closed (EOF).
@@ -62,10 +60,13 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
6260 _ = outW.Close()
6361 <-done
6462
65- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
63+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
6664 if got := captured.String(); got != want {
6765 t.Fatalf("captured escapes = %q, want %q", got, want)
6866 }
67+ if got := captured.String(); bytes.Contains([]byte(got), []byte("\x1b]10;?")) || bytes.Contains([]byte(got), []byte("\x1b]4;")) {
68+ t.Fatalf("EnterRaw emitted a color query: %q", got)
69+ }
6970 }
7071
7172 func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
@@ -100,37 +101,14 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
100101 _ = outW.Close()
101102 <-done
102103
103- // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and
104- // cursor/alt-screen exits exactly once, regardless of how many times
105- // EnterRaw/restore were called.
106- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
104+ // Alt-screen/cursor escapes must appear exactly once for enter and exits
105+ // exactly once, regardless of how many times EnterRaw/restore were called.
106+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
107107 if got := captured.String(); got != want {
108108 t.Fatalf("captured escapes = %q, want %q", got, want)
109109 }
110110 }
111111
112-func TestTerminal_QueryColors(t *testing.T) {
113- inR, inW, err := os.Pipe()
114- if err != nil {
115- t.Fatalf("os.Pipe(in): %v", err)
116- }
117- defer func() { _ = inW.Close() }()
118- defer func() { _ = inR.Close() }()
119-
120- outR, outW, captured, done := pipeCapture(t)
121- defer func() { _ = outR.Close() }()
122-
123- tm := NewWithFiles(inR, outW)
124- if err := tm.QueryColors(); err != nil {
125- t.Fatalf("QueryColors: %v", err)
126- }
127- _ = outW.Close()
128- <-done
129- if got := captured.String(); got != wantColorQueries {
130- t.Fatalf("captured = %q, want %q", got, wantColorQueries)
131- }
132-}
133-
134112 func TestTerminal_InOut(t *testing.T) {
135113 inR, inW, err := os.Pipe()
136114 if err != nil {
@@ -10,8 +10,6 @@ import (
10 "github.com/bnema/vev/internal/domain"10 "github.com/bnema/vev/internal/domain"
11 )11 )
12 12
13-const wantColorQueries = "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07"
14-
15 // pipePair returns an os.Pipe and a goroutine that continuously copies13 // pipePair returns an os.Pipe and a goroutine that continuously copies
16 // everything written to the write end into captured, closing done once14 // everything written to the write end into captured, closing done once
17 // the write end is closed (EOF).15 // the write end is closed (EOF).
@@ -62,10 +60,13 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) {
62 _ = outW.Close()60 _ = outW.Close()
63 <-done61 <-done
64 62
65- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit63+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
66 if got := captured.String(); got != want {64 if got := captured.String(); got != want {
67 t.Fatalf("captured escapes = %q, want %q", got, want)65 t.Fatalf("captured escapes = %q, want %q", got, want)
68 }66 }
67+ if got := captured.String(); bytes.Contains([]byte(got), []byte("\x1b]10;?")) || bytes.Contains([]byte(got), []byte("\x1b]4;")) {
68+ t.Fatalf("EnterRaw emitted a color query: %q", got)
69+ }
69 }70 }
70 71
71 func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {72 func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
@@ -100,37 +101,14 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) {
100 _ = outW.Close()101 _ = outW.Close()
101 <-done102 <-done
102 103
103- // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and104+ // Alt-screen/cursor escapes must appear exactly once for enter and exits
104- // cursor/alt-screen exits exactly once, regardless of how many times105+ // exactly once, regardless of how many times EnterRaw/restore were called.
105- // EnterRaw/restore were called.106+ want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
106- want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + wantColorQueries + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit
107 if got := captured.String(); got != want {107 if got := captured.String(); got != want {
108 t.Fatalf("captured escapes = %q, want %q", got, want)108 t.Fatalf("captured escapes = %q, want %q", got, want)
109 }109 }
110 }110 }
111 111
112-func TestTerminal_QueryColors(t *testing.T) {
113- inR, inW, err := os.Pipe()
114- if err != nil {
115- t.Fatalf("os.Pipe(in): %v", err)
116- }
117- defer func() { _ = inW.Close() }()
118- defer func() { _ = inR.Close() }()
119-
120- outR, outW, captured, done := pipeCapture(t)
121- defer func() { _ = outR.Close() }()
122-
123- tm := NewWithFiles(inR, outW)
124- if err := tm.QueryColors(); err != nil {
125- t.Fatalf("QueryColors: %v", err)
126- }
127- _ = outW.Close()
128- <-done
129- if got := captured.String(); got != wantColorQueries {
130- t.Fatalf("captured = %q, want %q", got, wantColorQueries)
131- }
132-}
133-
134 func TestTerminal_InOut(t *testing.T) {112 func TestTerminal_InOut(t *testing.T) {
135 inR, inW, err := os.Pipe()113 inR, inW, err := os.Pipe()
136 if err != nil {114 if err != nil {
M internal/ports/mocks/terminal_mock.go +0 -44
@@ -229,50 +229,6 @@ func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTermina
229229 return _c
230230 }
231231
232-// QueryColors provides a mock function for the type MockTerminal
233-func (_mock *MockTerminal) QueryColors() error {
234- ret := _mock.Called()
235-
236- if len(ret) == 0 {
237- panic("no return value specified for QueryColors")
238- }
239-
240- var r0 error
241- if returnFunc, ok := ret.Get(0).(func() error); ok {
242- r0 = returnFunc()
243- } else {
244- r0 = ret.Error(0)
245- }
246- return r0
247-}
248-
249-// MockTerminal_QueryColors_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueryColors'
250-type MockTerminal_QueryColors_Call struct {
251- *mock.Call
252-}
253-
254-// QueryColors is a helper method to define mock.On call
255-func (_e *MockTerminal_Expecter) QueryColors() *MockTerminal_QueryColors_Call {
256- return &MockTerminal_QueryColors_Call{Call: _e.mock.On("QueryColors")}
257-}
258-
259-func (_c *MockTerminal_QueryColors_Call) Run(run func()) *MockTerminal_QueryColors_Call {
260- _c.Call.Run(func(args mock.Arguments) {
261- run()
262- })
263- return _c
264-}
265-
266-func (_c *MockTerminal_QueryColors_Call) Return(err error) *MockTerminal_QueryColors_Call {
267- _c.Call.Return(err)
268- return _c
269-}
270-
271-func (_c *MockTerminal_QueryColors_Call) RunAndReturn(run func() error) *MockTerminal_QueryColors_Call {
272- _c.Call.Return(run)
273- return _c
274-}
275-
276232 // ResizeEvents provides a mock function for the type MockTerminal
277233 func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size {
278234 ret := _mock.Called()
@@ -229,50 +229,6 @@ func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTermina
229 return _c229 return _c
230 }230 }
231 231
232-// QueryColors provides a mock function for the type MockTerminal
233-func (_mock *MockTerminal) QueryColors() error {
234- ret := _mock.Called()
235-
236- if len(ret) == 0 {
237- panic("no return value specified for QueryColors")
238- }
239-
240- var r0 error
241- if returnFunc, ok := ret.Get(0).(func() error); ok {
242- r0 = returnFunc()
243- } else {
244- r0 = ret.Error(0)
245- }
246- return r0
247-}
248-
249-// MockTerminal_QueryColors_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'QueryColors'
250-type MockTerminal_QueryColors_Call struct {
251- *mock.Call
252-}
253-
254-// QueryColors is a helper method to define mock.On call
255-func (_e *MockTerminal_Expecter) QueryColors() *MockTerminal_QueryColors_Call {
256- return &MockTerminal_QueryColors_Call{Call: _e.mock.On("QueryColors")}
257-}
258-
259-func (_c *MockTerminal_QueryColors_Call) Run(run func()) *MockTerminal_QueryColors_Call {
260- _c.Call.Run(func(args mock.Arguments) {
261- run()
262- })
263- return _c
264-}
265-
266-func (_c *MockTerminal_QueryColors_Call) Return(err error) *MockTerminal_QueryColors_Call {
267- _c.Call.Return(err)
268- return _c
269-}
270-
271-func (_c *MockTerminal_QueryColors_Call) RunAndReturn(run func() error) *MockTerminal_QueryColors_Call {
272- _c.Call.Return(run)
273- return _c
274-}
275-
276 // ResizeEvents provides a mock function for the type MockTerminal232 // ResizeEvents provides a mock function for the type MockTerminal
277 func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size {233 func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size {
278 ret := _mock.Called()234 ret := _mock.Called()
M internal/ports/terminal.go +0 -1
@@ -11,7 +11,6 @@ type Terminal interface {
1111 EnterRaw() (restore func() error, err error)
1212 Size() (domain.Size, error)
1313 ResizeEvents() <-chan domain.Size
14- QueryColors() error
1514 In() io.Reader
1615 Out() io.Writer
1716 Flush() error
@@ -11,7 +11,6 @@ type Terminal interface {
11 EnterRaw() (restore func() error, err error)11 EnterRaw() (restore func() error, err error)
12 Size() (domain.Size, error)12 Size() (domain.Size, error)
13 ResizeEvents() <-chan domain.Size13 ResizeEvents() <-chan domain.Size
14- QueryColors() error
15 In() io.Reader14 In() io.Reader
16 Out() io.Writer15 Out() io.Writer
17 Flush() error16 Flush() error
M internal/usecase/client/client.go +156 -243
@@ -586,50 +586,121 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
586586 return welcomedResult(err)
587587 }
588588 reconnect.clear()
589- reportedTheme, restoreTheme := themeState.reportedTheme()
590589
591590 // 4. Derive a cancellable context so the pumps always unwind when the
592- // loop returns. cancel runs first (LIFO): it signals the pumps; the
593- // deferred Close above then unblocks any pump parked in a syscall.
591+ // loop returns. The attach loop is the sole terminal writer and palette
592+ // coordinator owner.
594593 loopCtx, cancel := context.WithCancel(ctx)
595594 defer cancel()
596595
597596 sendCh := make(chan ports.Frame, sendQueueDepth)
598- if restoreTheme {
599- sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(reportedTheme)}
600- }
601597 ackQueue := newCumulativeAckQueue()
602598 sendErrCh := make(chan error, 1)
603-
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.
610- colorQueryCh := make(chan colorQueryRequest)
611- paletteReadyCh := make(chan paletteQueryReady)
612- requestColors := func() (colorQueryRequest, bool) {
613- request := colorQueryRequest{boundaryWritten: make(chan struct{})}
614- select {
615- case colorQueryCh <- request:
616- case <-loopCtx.Done():
617- return colorQueryRequest{}, false
599+ senderStarted := false
600+ paletteEvents := make(chan paletteGenerationEvent, 32)
601+ var activeGeneration atomic.Uint64
602+ coordinator := newPaletteGenerationCoordinator()
603+ type paletteTimer struct {
604+ timer ports.Timer
605+ cancel chan struct{}
606+ }
607+ drainTimers := map[paletteGenerationID]paletteTimer{}
608+ completionTimers := map[paletteGenerationID]paletteTimer{}
609+ cancelTimer := func(timers map[paletteGenerationID]paletteTimer, id paletteGenerationID) {
610+ if timer, ok := timers[id]; ok {
611+ timer.timer.Stop()
612+ close(timer.cancel)
613+ delete(timers, id)
614+ }
615+ }
616+ publish := func(snapshot ports.Theme) bool {
617+ frame := ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snapshot)}
618+ // The initial cleared snapshot is sent before the sender exists. This
619+ // keeps the generation publication ordered before the first query and
620+ // avoids leaving a queued Theme behind an immediate detach.
621+ if !senderStarted {
622+ return transport.Send(frame) == nil
618623 }
619624 select {
620- case <-request.boundaryWritten:
621- return request, true
625+ case sendCh <- frame:
626+ return true
622627 case <-loopCtx.Done():
623- return colorQueryRequest{}, false
628+ return false
624629 }
625630 }
631+ processPaletteActions := func(actions []paletteGenerationAction) error {
632+ for _, action := range actions {
633+ switch action.kind {
634+ case paletteActionPublishCleared:
635+ if !publish(action.theme) {
636+ return context.Canceled
637+ }
638+ case paletteActionPublishFinal:
639+ themeState.update(func(current *ports.Theme) { *current = action.theme })
640+ if !publish(action.theme) {
641+ return context.Canceled
642+ }
643+ case paletteActionWriteDrain, paletteActionWriteBatch:
644+ if _, err := term.Out().Write([]byte(action.bytes)); err != nil {
645+ return fmt.Errorf("writing palette query: %w", err)
646+ }
647+ if err := term.Flush(); err != nil {
648+ return fmt.Errorf("flushing palette query: %w", err)
649+ }
650+ case paletteActionArmDrainDeadline, paletteActionArmCompletionDeadline:
651+ timer := clk.NewTimer(action.deadline)
652+ entry := paletteTimer{timer: timer, cancel: make(chan struct{})}
653+ timers := drainTimers
654+ kind := paletteEventDrainDeadline
655+ if action.kind == paletteActionArmCompletionDeadline {
656+ timers = completionTimers
657+ kind = paletteEventCompletionDeadline
658+ }
659+ timers[action.id] = entry
660+ go func(id paletteGenerationID, eventKind paletteGenerationEventKind, timer paletteTimer) {
661+ select {
662+ case <-timer.timer.C():
663+ select {
664+ case paletteEvents <- paletteGenerationEvent{id: id, kind: eventKind}:
665+ case <-loopCtx.Done():
666+ }
667+ case <-timer.cancel:
668+ case <-loopCtx.Done():
669+ }
670+ }(action.id, kind, entry)
671+ case paletteActionCancelDrainDeadline:
672+ cancelTimer(drainTimers, action.id)
673+ case paletteActionCancelCompletionDeadline:
674+ cancelTimer(completionTimers, action.id)
675+ }
676+ }
677+ return nil
678+ }
679+ defer func() {
680+ for id := range drainTimers {
681+ cancelTimer(drainTimers, id)
682+ }
683+ for id := range completionTimers {
684+ cancelTimer(completionTimers, id)
685+ }
686+ }()
687+
688+ // Initial acquisition is a generation too. A reconnect publishes its one
689+ // cleared snapshot from retained definitive colors, never a restore frame.
690+ retained := themeState.snapshot()
691+ initialActions := coordinator.start(retained, false)
692+ activeGeneration.Store(uint64(coordinator.current.id))
693+ if err := processPaletteActions(initialActions); err != nil {
694+ return welcomedResult(err)
695+ }
626696
627697 var clip ports.ClipboardReader
628698 if remote {
629699 clip = clipboard
630700 }
701+ senderStarted = true
631702 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)
632- go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, themeState: themeState, clipboard: clip, logger: log, requestColors: requestColors, paletteReady: paletteReadyCh}).run()
703+ go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()
633704 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
634705
635706 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -668,29 +739,22 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
668739 // token. Run closes this transport on the way out.
669740 return welcomedResult(errLinkOffline)
670741 }
671- case request := <-colorQueryCh:
672- if _, err := term.Out().Write([]byte(paletteBoundaryQuery)); err != nil {
673- return welcomedResult(fmt.Errorf("vev: writing palette boundary query: %w", err))
674- }
675- if err := term.Flush(); err != nil {
676- return welcomedResult(fmt.Errorf("vev: flushing palette boundary query: %w", err))
677- }
678- close(request.boundaryWritten)
679- case ready := <-paletteReadyCh:
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.
684- clearedTheme := themeState.snapshot()
685- select {
686- case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}:
687- case <-loopCtx.Done():
688- return welcomedResult(nil)
742+ case event := <-paletteEvents:
743+ if event.kind == paletteEventScheme {
744+ retained := themeState.update(func(current *ports.Theme) {
745+ current.SchemeKnown = true
746+ current.Light = event.light
747+ })
748+ actions := coordinator.start(retained, true)
749+ activeGeneration.Store(uint64(coordinator.current.id))
750+ if err := processPaletteActions(actions); err != nil {
751+ return welcomedResult(err)
752+ }
753+ continue
689754 }
690- if err := term.QueryColors(); err != nil {
691- log.Warn("querying terminal colors", "err", err)
755+ if err := processPaletteActions(coordinator.handle(event)); err != nil {
756+ return welcomedResult(err)
692757 }
693- close(ready.queryIssued)
694758 case r := <-recvCh:
695759 if r.err != nil {
696760 if errors.Is(r.err, io.EOF) {
@@ -848,148 +912,57 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
848912 // exits. That is harmless here — Run has already returned and restored the
849913 // terminal — and matches the standard pattern for stdin pumps; a
850914 // closable stdin duplicate could lift it later if ever needed.
851-type colorQueryRequest struct {
852- boundaryWritten chan struct{}
853-}
854-
855-type paletteQueryReady struct {
856- queryIssued chan struct{}
857-}
858-
859-type paletteDrain struct {
860- pending []byte
861-}
862-
863-type palettePhase uint8
864-
865-const (
866- paletteAccepting palettePhase = iota
867- paletteBoundaryRequested
868- paletteDraining
869- paletteAwaitingQuery
870-)
871-
872-// paletteBoundaryResponses accepts valid DECRQM status values for private
873-// mode 2031. The private mode number correlates this reply with the query
874-// that starts a replacement palette generation.
875-var paletteBoundaryResponses = [][]byte{
876- []byte("\x1b[?2031;0$y"),
877- []byte("\x1b[?2031;1$y"),
878- []byte("\x1b[?2031;2$y"),
879- []byte("\x1b[?2031;3$y"),
880- []byte("\x1b[?2031;4$y"),
881-}
882-
883-// scan consumes the private-mode boundary response while forwarding every
884-// other byte in order. It retains only a possible response prefix, so split
885-// replies are consumed without dropping a user's partial escape sequence on
886-// cancellation.
887-func (d *paletteDrain) scan(data []byte, onBytes func([]byte), onBoundary func()) {
888- for _, b := range data {
889- d.pending = append(d.pending, b)
890- for len(d.pending) > 0 {
891- matched := false
892- possible := false
893- for _, response := range paletteBoundaryResponses {
894- if len(d.pending) > len(response) {
895- continue
896- }
897- match := true
898- for i := range d.pending {
899- if d.pending[i] != response[i] {
900- match = false
901- break
902- }
903- }
904- if !match {
905- continue
906- }
907- if len(d.pending) == len(response) {
908- matched = true
909- } else {
910- possible = true
911- }
912- }
913- if matched {
914- d.pending = nil
915- onBoundary()
916- break
917- }
918- if possible {
919- break
920- }
921- onBytes(d.pending[:1])
922- d.pending = d.pending[1:]
923- }
924- }
925-}
926-
927-func (d *paletteDrain) flush(onBytes func([]byte)) {
928- if len(d.pending) != 0 {
929- onBytes(d.pending)
930- d.pending = nil
931- }
932-}
933-
915+// stdinPump parses terminal color reports into generation-tagged events while
916+// preserving all ordinary terminal input byte-for-byte. It never publishes a
917+// Theme and never writes terminal output; attachAttempt owns both operations.
934918 type stdinPump struct {
935- ctx context.Context
936- cancel context.CancelFunc
937- in io.Reader
938- out chan<- ports.Frame
939- clock ports.Clock
940- themeState *terminalThemeState
941- clipboard ports.ClipboardReader
942- logger *slog.Logger
943- requestColors func() (colorQueryRequest, bool)
944- paletteReady chan<- paletteQueryReady
919+ ctx context.Context
920+ cancel context.CancelFunc
921+ in io.Reader
922+ out chan<- ports.Frame
923+ clock ports.Clock
924+ clipboard ports.ClipboardReader
925+ logger *slog.Logger
926+ paletteEvents chan<- paletteGenerationEvent
927+ activeGeneration *atomic.Uint64
945928 }
946929
947930 func (p *stdinPump) run() {
948- ctx := p.ctx
949- cancel := p.cancel
950- in := p.in
951- out := p.out
952- clk := p.clock
953- themeState := p.themeState
954- requestColors := p.requestColors
955- clipboard := p.clipboard
956- log := p.logger
957- defer log.Debug("stdin pump exited")
931+ defer p.logger.Debug("stdin pump exited")
958932 buf := make([]byte, stdinBufSize)
959933 var scanner theme.Scanner
934+ var markers paletteMarkerScanner
960935 var inputSeq uint64
961936 var sendOK atomic.Bool
962937 sendOK.Store(true)
963938 send := func(frame ports.Frame) {
964939 select {
965- case out <- frame:
966- case <-ctx.Done():
940+ case p.out <- frame:
941+ case <-p.ctx.Done():
942+ sendOK.Store(false)
943+ }
944+ }
945+ sendEvent := func(event paletteGenerationEvent) {
946+ select {
947+ case p.paletteEvents <- event:
948+ case <-p.ctx.Done():
967949 sendOK.Store(false)
968950 }
969951 }
970- // The coalescer reframes a bracketed paste split across reads into one
971- // MsgInput frame, so a marker boundary can never leave a lone ESC on the
972- // wire. Its emit runs one MsgInput per call, keeping the inputSeq contract.
973- coalescer := newPasteCoalescer(clk, func(data []byte) {
952+ coalescer := newPasteCoalescer(p.clock, func(data []byte) {
974953 if len(data) == 0 {
975954 return
976955 }
977- copyData := append([]byte(nil), data...)
978956 inputSeq++
979- send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})
957+ send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: append([]byte(nil), data...)})})
980958 })
981959 defer coalescer.Close()
982-
983- // On a remote attach with a ClipboardReader configured, splice in the
984- // clipboard interceptor ahead of the coalescer so a bare Ctrl+V (0x16)
985- // becomes a clipboard image push instead of reaching the coalescer (and
986- // from there the remote pane) as an ordinary keystroke.
987960 sink := coalescer.Scan
988- if clipboard != nil {
961+ if p.clipboard != nil {
989962 ci := &clipboardIntercept{
990963 coalescer: coalescer,
991- reader: clipboard,
992- log: log,
964+ reader: p.clipboard,
965+ log: p.logger,
993966 sendImage: func(mime string, data []byte) {
994967 inputSeq++
995968 send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})
@@ -998,98 +971,38 @@ func (p *stdinPump) run() {
998971 }
999972 sink = ci.Scan
1000973 }
1001-
1002- phase := paletteAccepting
1003- var drain paletteDrain
974+ id := func() paletteGenerationID {
975+ return paletteGenerationID(p.activeGeneration.Load())
976+ }
1004977 for {
1005- n, rerr := in.Read(buf)
978+ n, rerr := p.in.Read(buf)
1006979 if n > 0 {
1007- themeChanged := false
1008- queryNeeded := false
1009- onBytes := func(data []byte) {
1010- if phase != paletteDraining {
1011- sink(data)
1012- return
1013- }
1014- drain.scan(data, sink, func() {
1015- phase = paletteAwaitingQuery
1016- ready := paletteQueryReady{queryIssued: make(chan struct{})}
1017- select {
1018- case p.paletteReady <- ready:
1019- case <-ctx.Done():
1020- return
1021- }
1022- select {
1023- case <-ready.queryIssued:
1024- phase = paletteAccepting
1025- case <-ctx.Done():
1026- }
1027- })
1028- }
1029980 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
1030- if phase != paletteAccepting {
1031- return
981+ kindEvent := paletteEventForeground
982+ if kind == 11 {
983+ kindEvent = paletteEventBackground
1032984 }
1033- themeState.update(func(current *ports.Theme) {
1034- switch kind {
1035- case 10:
1036- current.HasForeground = true
1037- current.Foreground = rgb
1038- case 11:
1039- current.HasBackground = true
1040- current.Background = rgb
1041- }
1042- })
1043- themeChanged = true
985+ sendEvent(paletteGenerationEvent{id: id(), kind: kindEvent, rgb: rgb})
1044986 }, func(slot int, rgb renderer.RGB) {
1045- if phase != paletteAccepting {
1046- return
1047- }
1048- themeState.update(func(current *ports.Theme) {
1049- current.PaletteKnown |= uint16(1) << slot
1050- current.Palette[slot] = rgb
1051- })
1052- themeChanged = true
987+ sendEvent(paletteGenerationEvent{id: id(), kind: paletteEventPalette, slot: uint8(slot), rgb: rgb})
1053988 }, func(light bool) {
1054- themeState.update(func(current *ports.Theme) {
1055- current.SchemeKnown = true
1056- current.Light = light
989+ sendEvent(paletteGenerationEvent{id: id(), kind: paletteEventScheme, light: light})
990+ }, func(data []byte) {
991+ markers.scan(data, sink, func() {
992+ sendEvent(paletteGenerationEvent{id: id(), kind: paletteEventMarker})
1057993 })
1058- // The scheme itself begins a new palette generation. Suppress every
1059- // color reply after it, including those remaining in this read, and
1060- // defer invalidation until Scan completes so no partial snapshot is
1061- // observable mid-chunk.
1062- themeChanged = false
1063- if phase == paletteAccepting {
1064- phase = paletteBoundaryRequested
1065- queryNeeded = true
1066- }
1067- }, onBytes)
1068- if queryNeeded {
1069- themeState.clearPalette()
1070- if requestColors != nil {
1071- if _, ok := requestColors(); !ok {
1072- return
1073- }
1074- phase = paletteDraining
1075- }
1076- } else if themeChanged {
1077- send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(themeState.snapshot())})
1078- }
994+ })
1079995 if !sendOK.Load() {
1080996 return
1081997 }
1082998 }
1083999 if rerr != nil {
1084- if phase == paletteDraining {
1085- drain.flush(sink)
1086- }
1087- // Best-effort detach notification, then unwind.
1000+ markers.flush(sink)
10881001 select {
1089- case out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}:
1090- case <-ctx.Done():
1002+ case p.out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}:
1003+ case <-p.ctx.Done():
10911004 }
1092- cancel()
1005+ p.cancel()
10931006 return
10941007 }
10951008 }
@@ -586,50 +586,121 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
586 return welcomedResult(err)586 return welcomedResult(err)
587 }587 }
588 reconnect.clear()588 reconnect.clear()
589- reportedTheme, restoreTheme := themeState.reportedTheme()
590 589
591 // 4. Derive a cancellable context so the pumps always unwind when the590 // 4. Derive a cancellable context so the pumps always unwind when the
592- // loop returns. cancel runs first (LIFO): it signals the pumps; the591+ // loop returns. The attach loop is the sole terminal writer and palette
593- // deferred Close above then unblocks any pump parked in a syscall.592+ // coordinator owner.
594 loopCtx, cancel := context.WithCancel(ctx)593 loopCtx, cancel := context.WithCancel(ctx)
595 defer cancel()594 defer cancel()
596 595
597 sendCh := make(chan ports.Frame, sendQueueDepth)596 sendCh := make(chan ports.Frame, sendQueueDepth)
598- if restoreTheme {
599- sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(reportedTheme)}
600- }
601 ackQueue := newCumulativeAckQueue()597 ackQueue := newCumulativeAckQueue()
602 sendErrCh := make(chan error, 1)598 sendErrCh := make(chan error, 1)
603-599+ senderStarted := false
604- // Palette re-queries use a private-mode query as an ordered boundary. The600+ paletteEvents := make(chan paletteGenerationEvent, 32)
605- // stdin pump drains terminal replies through its mode-specific response601+ var activeGeneration atomic.Uint64
606- // before it permits the main loop to publish the cleared scheme and issue602+ coordinator := newPaletteGenerationCoordinator()
607- // QueryColors. This separates bytes already queued (including603+ type paletteTimer struct {
608- // Scanner.pending) from replacement replies without allowing a second604+ timer ports.Timer
609- // goroutine to write to the terminal.605+ cancel chan struct{}
610- colorQueryCh := make(chan colorQueryRequest)606+ }
611- paletteReadyCh := make(chan paletteQueryReady)607+ drainTimers := map[paletteGenerationID]paletteTimer{}
612- requestColors := func() (colorQueryRequest, bool) {608+ completionTimers := map[paletteGenerationID]paletteTimer{}
613- request := colorQueryRequest{boundaryWritten: make(chan struct{})}609+ cancelTimer := func(timers map[paletteGenerationID]paletteTimer, id paletteGenerationID) {
614- select {610+ if timer, ok := timers[id]; ok {
615- case colorQueryCh <- request:611+ timer.timer.Stop()
616- case <-loopCtx.Done():612+ close(timer.cancel)
617- return colorQueryRequest{}, false613+ delete(timers, id)
614+ }
615+ }
616+ publish := func(snapshot ports.Theme) bool {
617+ frame := ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(snapshot)}
618+ // The initial cleared snapshot is sent before the sender exists. This
619+ // keeps the generation publication ordered before the first query and
620+ // avoids leaving a queued Theme behind an immediate detach.
621+ if !senderStarted {
622+ return transport.Send(frame) == nil
618 }623 }
619 select {624 select {
620- case <-request.boundaryWritten:625+ case sendCh <- frame:
621- return request, true626+ return true
622 case <-loopCtx.Done():627 case <-loopCtx.Done():
623- return colorQueryRequest{}, false628+ return false
624 }629 }
625 }630 }
631+ processPaletteActions := func(actions []paletteGenerationAction) error {
632+ for _, action := range actions {
633+ switch action.kind {
634+ case paletteActionPublishCleared:
635+ if !publish(action.theme) {
636+ return context.Canceled
637+ }
638+ case paletteActionPublishFinal:
639+ themeState.update(func(current *ports.Theme) { *current = action.theme })
640+ if !publish(action.theme) {
641+ return context.Canceled
642+ }
643+ case paletteActionWriteDrain, paletteActionWriteBatch:
644+ if _, err := term.Out().Write([]byte(action.bytes)); err != nil {
645+ return fmt.Errorf("writing palette query: %w", err)
646+ }
647+ if err := term.Flush(); err != nil {
648+ return fmt.Errorf("flushing palette query: %w", err)
649+ }
650+ case paletteActionArmDrainDeadline, paletteActionArmCompletionDeadline:
651+ timer := clk.NewTimer(action.deadline)
652+ entry := paletteTimer{timer: timer, cancel: make(chan struct{})}
653+ timers := drainTimers
654+ kind := paletteEventDrainDeadline
655+ if action.kind == paletteActionArmCompletionDeadline {
656+ timers = completionTimers
657+ kind = paletteEventCompletionDeadline
658+ }
659+ timers[action.id] = entry
660+ go func(id paletteGenerationID, eventKind paletteGenerationEventKind, timer paletteTimer) {
661+ select {
662+ case <-timer.timer.C():
663+ select {
664+ case paletteEvents <- paletteGenerationEvent{id: id, kind: eventKind}:
665+ case <-loopCtx.Done():
666+ }
667+ case <-timer.cancel:
668+ case <-loopCtx.Done():
669+ }
670+ }(action.id, kind, entry)
671+ case paletteActionCancelDrainDeadline:
672+ cancelTimer(drainTimers, action.id)
673+ case paletteActionCancelCompletionDeadline:
674+ cancelTimer(completionTimers, action.id)
675+ }
676+ }
677+ return nil
678+ }
679+ defer func() {
680+ for id := range drainTimers {
681+ cancelTimer(drainTimers, id)
682+ }
683+ for id := range completionTimers {
684+ cancelTimer(completionTimers, id)
685+ }
686+ }()
687+
688+ // Initial acquisition is a generation too. A reconnect publishes its one
689+ // cleared snapshot from retained definitive colors, never a restore frame.
690+ retained := themeState.snapshot()
691+ initialActions := coordinator.start(retained, false)
692+ activeGeneration.Store(uint64(coordinator.current.id))
693+ if err := processPaletteActions(initialActions); err != nil {
694+ return welcomedResult(err)
695+ }
626 696
627 var clip ports.ClipboardReader697 var clip ports.ClipboardReader
628 if remote {698 if remote {
629 clip = clipboard699 clip = clipboard
630 }700 }
701+ senderStarted = true
631 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)702 go runSender(loopCtx, cancel, transport, sendCh, ackQueue, sendErrCh, log)
632- go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, themeState: themeState, clipboard: clip, logger: log, requestColors: requestColors, paletteReady: paletteReadyCh}).run()703+ go (&stdinPump{ctx: loopCtx, cancel: cancel, in: term.In(), out: sendCh, clock: clk, clipboard: clip, logger: log, paletteEvents: paletteEvents, activeGeneration: &activeGeneration}).run()
633 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)704 go runResize(loopCtx, term.ResizeEvents(), sendCh, log)
634 705
635 // 5. Output/main loop: the only goroutine that touches the terminal.706 // 5. Output/main loop: the only goroutine that touches the terminal.
@@ -668,29 +739,22 @@ func (a *attachAttempt) run(ctx context.Context) attachResult {
668 // token. Run closes this transport on the way out.739 // token. Run closes this transport on the way out.
669 return welcomedResult(errLinkOffline)740 return welcomedResult(errLinkOffline)
670 }741 }
671- case request := <-colorQueryCh:742+ case event := <-paletteEvents:
672- if _, err := term.Out().Write([]byte(paletteBoundaryQuery)); err != nil {743+ if event.kind == paletteEventScheme {
673- return welcomedResult(fmt.Errorf("vev: writing palette boundary query: %w", err))744+ retained := themeState.update(func(current *ports.Theme) {
674- }745+ current.SchemeKnown = true
675- if err := term.Flush(); err != nil {746+ current.Light = event.light
676- return welcomedResult(fmt.Errorf("vev: flushing palette boundary query: %w", err))747+ })
677- }748+ actions := coordinator.start(retained, true)
678- close(request.boundaryWritten)749+ activeGeneration.Store(uint64(coordinator.current.id))
679- case ready := <-paletteReadyCh:750+ if err := processPaletteActions(actions); err != nil {
680- // The private-mode response proves all earlier replies have been751+ return welcomedResult(err)
681- // discarded by stdin. Publish the one cleared scheme snapshot before752+ }
682- // emitting the query; stdin remains paused until QueryColors has753+ continue
683- // completed below.
684- clearedTheme := themeState.snapshot()
685- select {
686- case sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(clearedTheme)}:
687- case <-loopCtx.Done():
688- return welcomedResult(nil)
689 }754 }
690- if err := term.QueryColors(); err != nil {755+ if err := processPaletteActions(coordinator.handle(event)); err != nil {
691- log.Warn("querying terminal colors", "err", err)756+ return welcomedResult(err)
692 }757 }
693- close(ready.queryIssued)
694 case r := <-recvCh:758 case r := <-recvCh:
695 if r.err != nil {759 if r.err != nil {
696 if errors.Is(r.err, io.EOF) {760 if errors.Is(r.err, io.EOF) {
@@ -848,148 +912,57 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T
848 // exits. That is harmless here — Run has already returned and restored the912 // exits. That is harmless here — Run has already returned and restored the
849 // terminal — and matches the standard pattern for stdin pumps; a913 // terminal — and matches the standard pattern for stdin pumps; a
850 // closable stdin duplicate could lift it later if ever needed.914 // closable stdin duplicate could lift it later if ever needed.
851-type colorQueryRequest struct {915+// stdinPump parses terminal color reports into generation-tagged events while
852- boundaryWritten chan struct{}916+// preserving all ordinary terminal input byte-for-byte. It never publishes a
853-}917+// Theme and never writes terminal output; attachAttempt owns both operations.
854-
855-type paletteQueryReady struct {
856- queryIssued chan struct{}
857-}
858-
859-type paletteDrain struct {
860- pending []byte
861-}
862-
863-type palettePhase uint8
864-
865-const (
866- paletteAccepting palettePhase = iota
867- paletteBoundaryRequested
868- paletteDraining
869- paletteAwaitingQuery
870-)
871-
872-// paletteBoundaryResponses accepts valid DECRQM status values for private
873-// mode 2031. The private mode number correlates this reply with the query
874-// that starts a replacement palette generation.
875-var paletteBoundaryResponses = [][]byte{
876- []byte("\x1b[?2031;0$y"),
877- []byte("\x1b[?2031;1$y"),
878- []byte("\x1b[?2031;2$y"),
879- []byte("\x1b[?2031;3$y"),
880- []byte("\x1b[?2031;4$y"),
881-}
882-
883-// scan consumes the private-mode boundary response while forwarding every
884-// other byte in order. It retains only a possible response prefix, so split
885-// replies are consumed without dropping a user's partial escape sequence on
886-// cancellation.
887-func (d *paletteDrain) scan(data []byte, onBytes func([]byte), onBoundary func()) {
888- for _, b := range data {
889- d.pending = append(d.pending, b)
890- for len(d.pending) > 0 {
891- matched := false
892- possible := false
893- for _, response := range paletteBoundaryResponses {
894- if len(d.pending) > len(response) {
895- continue
896- }
897- match := true
898- for i := range d.pending {
899- if d.pending[i] != response[i] {
900- match = false
901- break
902- }
903- }
904- if !match {
905- continue
906- }
907- if len(d.pending) == len(response) {
908- matched = true
909- } else {
910- possible = true
911- }
912- }
913- if matched {
914- d.pending = nil
915- onBoundary()
916- break
917- }
918- if possible {
919- break
920- }
921- onBytes(d.pending[:1])
922- d.pending = d.pending[1:]
923- }
924- }
925-}
926-
927-func (d *paletteDrain) flush(onBytes func([]byte)) {
928- if len(d.pending) != 0 {
929- onBytes(d.pending)
930- d.pending = nil
931- }
932-}
933-
934 type stdinPump struct {918 type stdinPump struct {
935- ctx context.Context919+ ctx context.Context
936- cancel context.CancelFunc920+ cancel context.CancelFunc
937- in io.Reader921+ in io.Reader
938- out chan<- ports.Frame922+ out chan<- ports.Frame
939- clock ports.Clock923+ clock ports.Clock
940- themeState *terminalThemeState924+ clipboard ports.ClipboardReader
941- clipboard ports.ClipboardReader925+ logger *slog.Logger
942- logger *slog.Logger926+ paletteEvents chan<- paletteGenerationEvent
943- requestColors func() (colorQueryRequest, bool)927+ activeGeneration *atomic.Uint64
944- paletteReady chan<- paletteQueryReady
945 }928 }
946 929
947 func (p *stdinPump) run() {930 func (p *stdinPump) run() {
948- ctx := p.ctx931+ defer p.logger.Debug("stdin pump exited")
949- cancel := p.cancel
950- in := p.in
951- out := p.out
952- clk := p.clock
953- themeState := p.themeState
954- requestColors := p.requestColors
955- clipboard := p.clipboard
956- log := p.logger
957- defer log.Debug("stdin pump exited")
958 buf := make([]byte, stdinBufSize)932 buf := make([]byte, stdinBufSize)
959 var scanner theme.Scanner933 var scanner theme.Scanner
934+ var markers paletteMarkerScanner
960 var inputSeq uint64935 var inputSeq uint64
961 var sendOK atomic.Bool936 var sendOK atomic.Bool
962 sendOK.Store(true)937 sendOK.Store(true)
963 send := func(frame ports.Frame) {938 send := func(frame ports.Frame) {
964 select {939 select {
965- case out <- frame:940+ case p.out <- frame:
966- case <-ctx.Done():941+ case <-p.ctx.Done():
942+ sendOK.Store(false)
943+ }
944+ }
945+ sendEvent := func(event paletteGenerationEvent) {
946+ select {
947+ case p.paletteEvents <- event:
948+ case <-p.ctx.Done():
967 sendOK.Store(false)949 sendOK.Store(false)
968 }950 }
969 }951 }
970- // The coalescer reframes a bracketed paste split across reads into one952+ coalescer := newPasteCoalescer(p.clock, func(data []byte) {
971- // MsgInput frame, so a marker boundary can never leave a lone ESC on the
972- // wire. Its emit runs one MsgInput per call, keeping the inputSeq contract.
973- coalescer := newPasteCoalescer(clk, func(data []byte) {
974 if len(data) == 0 {953 if len(data) == 0 {
975 return954 return
976 }955 }
977- copyData := append([]byte(nil), data...)
978 inputSeq++956 inputSeq++
979- send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: copyData})})957+ send(ports.Frame{Type: ports.MsgInput, Payload: ports.MarshalInput(ports.Input{InputSeq: inputSeq, Data: append([]byte(nil), data...)})})
980 })958 })
981 defer coalescer.Close()959 defer coalescer.Close()
982-
983- // On a remote attach with a ClipboardReader configured, splice in the
984- // clipboard interceptor ahead of the coalescer so a bare Ctrl+V (0x16)
985- // becomes a clipboard image push instead of reaching the coalescer (and
986- // from there the remote pane) as an ordinary keystroke.
987 sink := coalescer.Scan960 sink := coalescer.Scan
988- if clipboard != nil {961+ if p.clipboard != nil {
989 ci := &clipboardIntercept{962 ci := &clipboardIntercept{
990 coalescer: coalescer,963 coalescer: coalescer,
991- reader: clipboard,964+ reader: p.clipboard,
992- log: log,965+ log: p.logger,
993 sendImage: func(mime string, data []byte) {966 sendImage: func(mime string, data []byte) {
994 inputSeq++967 inputSeq++
995 send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})968 send(ports.Frame{Type: ports.MsgImagePush, Payload: ports.MarshalImagePush(ports.ImagePush{InputSeq: inputSeq, Mime: mime, Data: data})})
@@ -998,98 +971,38 @@ func (p *stdinPump) run() {
998 }971 }
999 sink = ci.Scan972 sink = ci.Scan
1000 }973 }
1001-974+ id := func() paletteGenerationID {
1002- phase := paletteAccepting975+ return paletteGenerationID(p.activeGeneration.Load())
1003- var drain paletteDrain976+ }
1004 for {977 for {
1005- n, rerr := in.Read(buf)978+ n, rerr := p.in.Read(buf)
1006 if n > 0 {979 if n > 0 {
1007- themeChanged := false
1008- queryNeeded := false
1009- onBytes := func(data []byte) {
1010- if phase != paletteDraining {
1011- sink(data)
1012- return
1013- }
1014- drain.scan(data, sink, func() {
1015- phase = paletteAwaitingQuery
1016- ready := paletteQueryReady{queryIssued: make(chan struct{})}
1017- select {
1018- case p.paletteReady <- ready:
1019- case <-ctx.Done():
1020- return
1021- }
1022- select {
1023- case <-ready.queryIssued:
1024- phase = paletteAccepting
1025- case <-ctx.Done():
1026- }
1027- })
1028- }
1029 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {980 scanner.Scan(buf[:n], func(kind int, rgb renderer.RGB) {
1030- if phase != paletteAccepting {981+ kindEvent := paletteEventForeground
1031- return982+ if kind == 11 {
983+ kindEvent = paletteEventBackground
1032 }984 }
1033- themeState.update(func(current *ports.Theme) {985+ sendEvent(paletteGenerationEvent{id: id(), kind: kindEvent, rgb: rgb})
1034- switch kind {
1035- case 10:
1036- current.HasForeground = true
1037- current.Foreground = rgb
1038- case 11:
1039- current.HasBackground = true
1040- current.Background = rgb
1041- }
1042- })
1043- themeChanged = true
1044 }, func(slot int, rgb renderer.RGB) {986 }, func(slot int, rgb renderer.RGB) {
1045- if phase != paletteAccepting {987+ sendEvent(paletteGenerationEvent{id: id(), kind: paletteEventPalette, slot: uint8(slot), rgb: rgb})
1046- return
1047- }
1048- themeState.update(func(current *ports.Theme) {
1049- current.PaletteKnown |= uint16(1) << slot
1050- current.Palette[slot] = rgb
1051- })
1052- themeChanged = true
1053 }, func(light bool) {988 }, func(light bool) {
1054- themeState.update(func(current *ports.Theme) {989+ sendEvent(paletteGenerationEvent{id: id(), kind: paletteEventScheme, light: light})
1055- current.SchemeKnown = true990+ }, func(data []byte) {
1056- current.Light = light991+ markers.scan(data, sink, func() {
992+ sendEvent(paletteGenerationEvent{id: id(), kind: paletteEventMarker})
1057 })993 })
1058- // The scheme itself begins a new palette generation. Suppress every994+ })
1059- // color reply after it, including those remaining in this read, and
1060- // defer invalidation until Scan completes so no partial snapshot is
1061- // observable mid-chunk.
1062- themeChanged = false
1063- if phase == paletteAccepting {
1064- phase = paletteBoundaryRequested
1065- queryNeeded = true
1066- }
1067- }, onBytes)
1068- if queryNeeded {
1069- themeState.clearPalette()
1070- if requestColors != nil {
1071- if _, ok := requestColors(); !ok {
1072- return
1073- }
1074- phase = paletteDraining
1075- }
1076- } else if themeChanged {
1077- send(ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(themeState.snapshot())})
1078- }
1079 if !sendOK.Load() {995 if !sendOK.Load() {
1080 return996 return
1081 }997 }
1082 }998 }
1083 if rerr != nil {999 if rerr != nil {
1084- if phase == paletteDraining {1000+ markers.flush(sink)
1085- drain.flush(sink)
1086- }
1087- // Best-effort detach notification, then unwind.
1088 select {1001 select {
1089- case out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}:1002+ case p.out <- ports.Frame{Type: ports.MsgDetach, Payload: ports.MarshalDetach(ports.Detach{})}:
1090- case <-ctx.Done():1003+ case <-p.ctx.Done():
1091 }1004 }
1092- cancel()1005+ p.cancel()
1093 return1006 return
1094 }1007 }
1095 }1008 }
M internal/usecase/client/client_test.go +27 -365
@@ -230,6 +230,8 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
230230 closed := make(chan struct{})
231231 operationExited := make(chan struct{})
232232 tr := portsmocks.NewMockTransport(t)
233+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
234+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
233235 tr.EXPECT().Close().Run(func() { close(closed) }).Return(nil).Once()
234236
235237 block := func() error {
@@ -308,6 +310,8 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
308310
309311 gotHello := make(chan ports.Hello, 1)
310312 tr := portsmocks.NewMockTransport(t)
313+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
314+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
311315 tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
312316 hello, err := ports.UnmarshalHello(f.Payload)
313317 require.NoError(t, err)
@@ -345,6 +349,8 @@ func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {
345349
346350 gotHello := make(chan ports.Hello, 1)
347351 tr := portsmocks.NewMockTransport(t)
352+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
353+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
348354 tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
349355 hello, err := ports.UnmarshalHello(f.Payload)
350356 if err != nil {
@@ -372,6 +378,8 @@ func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
372378 defer in.unblock()
373379
374380 tr := portsmocks.NewMockTransport(t)
381+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
382+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
375383 tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
376384 hello, err := ports.UnmarshalHello(f.Payload)
377385 require.NoError(t, err)
@@ -396,6 +404,8 @@ func TestAttachHappyPath(t *testing.T) {
396404 defer in.unblock()
397405
398406 tr := portsmocks.NewMockTransport(t)
407+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
408+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
399409 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
400410 unblock := scriptRecv(tr,
401411 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main"}))},
@@ -407,7 +417,7 @@ func TestAttachHappyPath(t *testing.T) {
407417
408418 err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
409419 require.NoError(t, err)
410- require.Equal(t, "hello world", out.String())
420+ require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07\x1b[?2031$phello world", out.String())
411421 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")
412422 }
413423
@@ -417,6 +427,8 @@ func TestAttachVersionMismatch(t *testing.T) {
417427 // EnterRaw must NOT be called on the error-before-welcome path.
418428
419429 tr := portsmocks.NewMockTransport(t)
430+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
431+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
420432 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
421433 tr.EXPECT().Recv().Return(
422434 frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),
@@ -439,6 +451,8 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
439451 defer in.unblock()
440452
441453 tr := portsmocks.NewMockTransport(t)
454+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
455+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
442456 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
443457 boom := errors.New("connection reset")
444458 unblock := scriptRecv(tr,
@@ -461,6 +475,8 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
461475 defer in.unblock()
462476
463477 tr := portsmocks.NewMockTransport(t)
478+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
479+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
464480 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
465481 unblock := scriptRecv(tr,
466482 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
@@ -475,221 +491,6 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
475491 require.Equal(t, int32(1), restoreCount.Load())
476492 }
477493
478-func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
479- t.Setenv("COLORTERM", "truecolor")
480-
481- var out bytes.Buffer
482- var restoreCount atomic.Int32
483- resizeCh := make(chan domain.Size)
484- input := newOneShotBlockingReader([]byte("a\x1b]11;rgb:0101/0202/0303\x07b"))
485- defer input.unblock()
486-
487- tm := portsmocks.NewMockTerminal(t)
488- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
489- tm.EXPECT().EnterRaw().Return(func() error {
490- restoreCount.Add(1)
491- return nil
492- }, nil).Once()
493- tm.EXPECT().In().Return(input).Maybe()
494- tm.EXPECT().Out().Return(&out).Maybe()
495- tm.EXPECT().Flush().Return(nil).Maybe()
496- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
497-
498- gotTheme := make(chan ports.Theme, 1)
499- gotInput := make(chan []byte, 2)
500- allowDetach := make(chan struct{})
501-
502- tr := portsmocks.NewMockTransport(t)
503- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
504- tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
505- in, err := ports.UnmarshalInput(f.Payload)
506- require.NoError(t, err)
507- require.NotContains(t, string(in.Data), "\x1b]11;")
508- gotInput <- append([]byte(nil), in.Data...)
509- if bytes.Contains(in.Data, []byte("b")) {
510- close(allowDetach)
511- }
512- return nil
513- }).Maybe()
514- tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
515- th, err := ports.UnmarshalTheme(f.Payload)
516- require.NoError(t, err)
517- gotTheme <- th
518- return nil
519- }).Once()
520-
521- welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
522- detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
523- recvCh := make(chan recvItem, 1)
524- recvCh <- recvItem{f: welcome}
525- closed := make(chan struct{})
526- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
527- select {
528- case it := <-recvCh:
529- return it.f, it.err
530- case <-allowDetach:
531- select {
532- case <-closed:
533- return ports.Frame{}, io.EOF
534- default:
535- close(closed)
536- return detached, nil
537- }
538- case <-closed:
539- return ports.Frame{}, io.EOF
540- }
541- }).Maybe()
542- tr.EXPECT().Close().Return(nil).Once()
543-
544- err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
545- require.NoError(t, err)
546- require.Equal(t, int32(1), restoreCount.Load())
547-
548- select {
549- case th := <-gotTheme:
550- require.True(t, th.HasBackground)
551- require.Equal(t, uint8(1), th.Background.R)
552- require.Equal(t, uint8(2), th.Background.G)
553- require.Equal(t, uint8(3), th.Background.B)
554- require.True(t, th.TrueColor)
555- case <-time.After(2 * time.Second):
556- t.Fatal("theme frame was not sent")
557- }
558-
559- var inputBytes []byte
560- for {
561- select {
562- case b := <-gotInput:
563- inputBytes = append(inputBytes, b...)
564- default:
565- require.Equal(t, []byte("ab"), inputBytes)
566- return
567- }
568- }
569-}
570-
571-func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
572- t.Setenv("TERM", "xterm-256color")
573- t.Setenv("COLORTERM", "")
574-
575- var out bytes.Buffer
576- var restoreCount atomic.Int32
577- resizeCh := make(chan domain.Size)
578- input := newOneShotBlockingReader([]byte("\x1b]10;#010203\x07"))
579- defer input.unblock()
580-
581- tm := portsmocks.NewMockTerminal(t)
582- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
583- tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
584- tm.EXPECT().In().Return(input).Maybe()
585- tm.EXPECT().Out().Return(&out).Maybe()
586- tm.EXPECT().Flush().Return(nil).Maybe()
587- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
588-
589- gotTheme := make(chan ports.Theme, 1)
590- allowDetach := make(chan struct{})
591- tr := portsmocks.NewMockTransport(t)
592- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
593- tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
594- th, err := ports.UnmarshalTheme(f.Payload)
595- require.NoError(t, err)
596- gotTheme <- th
597- close(allowDetach)
598- return nil
599- }).Once()
600- welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
601- detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
602- recvCh := make(chan recvItem, 1)
603- recvCh <- recvItem{f: welcome}
604- closed := make(chan struct{})
605- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
606- select {
607- case it := <-recvCh:
608- return it.f, it.err
609- case <-allowDetach:
610- select {
611- case <-closed:
612- return ports.Frame{}, io.EOF
613- default:
614- close(closed)
615- return detached, nil
616- }
617- case <-closed:
618- return ports.Frame{}, io.EOF
619- }
620- }).Maybe()
621- tr.EXPECT().Close().Return(nil).Once()
622-
623- err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
624- require.NoError(t, err)
625- select {
626- case th := <-gotTheme:
627- require.True(t, th.HasForeground)
628- require.False(t, th.TrueColor)
629- case <-time.After(2 * time.Second):
630- t.Fatal("theme frame was not sent")
631- }
632-}
633-
634-func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
635- var out bytes.Buffer
636- var restoreCount atomic.Int32
637- resizeCh := make(chan domain.Size)
638- input := newOneShotBlockingReader([]byte("\x1b"))
639- defer input.unblock()
640-
641- tm := portsmocks.NewMockTerminal(t)
642- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
643- tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
644- tm.EXPECT().In().Return(input).Maybe()
645- tm.EXPECT().Out().Return(&out).Maybe()
646- tm.EXPECT().Flush().Return(nil).Maybe()
647- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
648-
649- gotInput := make(chan []byte, 1)
650- allowDetach := make(chan struct{})
651- tr := portsmocks.NewMockTransport(t)
652- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
653- tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
654- in, err := ports.UnmarshalInput(f.Payload)
655- require.NoError(t, err)
656- gotInput <- in.Data
657- close(allowDetach)
658- return nil
659- }).Once()
660- welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
661- detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
662- recvCh := make(chan recvItem, 1)
663- recvCh <- recvItem{f: welcome}
664- closed := make(chan struct{})
665- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
666- select {
667- case it := <-recvCh:
668- return it.f, it.err
669- case <-allowDetach:
670- select {
671- case <-closed:
672- return ports.Frame{}, io.EOF
673- default:
674- close(closed)
675- return detached, nil
676- }
677- case <-closed:
678- return ports.Frame{}, io.EOF
679- }
680- }).Maybe()
681- tr.EXPECT().Close().Return(nil).Once()
682-
683- err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
684- require.NoError(t, err)
685- select {
686- case got := <-gotInput:
687- require.Equal(t, []byte("\x1b"), got)
688- case <-time.After(2 * time.Second):
689- t.Fatal("standalone escape input was not sent")
690- }
691-}
692-
693494 func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
694495 var out bytes.Buffer
695496 var restoreCount atomic.Int32
@@ -708,6 +509,8 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
708509 gotInput := make(chan []byte, 2)
709510 allowDetach := make(chan struct{})
710511 tr := portsmocks.NewMockTransport(t)
512+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
513+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
711514 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
712515 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
713516 in, err := ports.UnmarshalInput(f.Payload)
@@ -768,6 +571,8 @@ func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
768571 gotInput := make(chan []byte, 1)
769572 allowDetach := make(chan struct{})
770573 tr := portsmocks.NewMockTransport(t)
574+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
575+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
771576 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
772577 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
773578 in, err := ports.UnmarshalInput(f.Payload)
@@ -821,6 +626,8 @@ func TestAttachForwardsResize(t *testing.T) {
821626 var firstRecvOnce sync.Once
822627
823628 tr := portsmocks.NewMockTransport(t)
629+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
630+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
824631 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
825632 // The resize frame is forwarded via the sender goroutine.
826633 gotResize := make(chan ports.Resize, 1)
@@ -880,6 +687,8 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
880687 defer in.unblock()
881688
882689 tr := portsmocks.NewMockTransport(t)
690+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
691+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
883692 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
884693 boom := errors.New("connection reset")
885694 unblock := scriptRecv(tr,
@@ -902,6 +711,8 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
902711 // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
903712
904713 tr := portsmocks.NewMockTransport(t)
714+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
715+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
905716 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
906717 tr.EXPECT().Recv().Return(
907718 frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),
@@ -987,7 +798,6 @@ func (t *runTerminal) EnterRaw() (func() error, error) {
987798 }
988799 func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }
989800 func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
990-func (t *runTerminal) QueryColors() error { return nil }
991801 func (t *runTerminal) In() io.Reader { return t.in }
992802 func (t *runTerminal) Out() io.Writer { return &t.out }
993803 func (t *runTerminal) Flush() error { return nil }
@@ -1047,151 +857,3 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
1047857 require.Equal(t, int32(1), d.calls.Load())
1048858 require.Equal(t, int32(1), term.restoreCount.Load())
1049859 }
1050-
1051-func TestAttachSchemeRequeryCorrelatesSplitBoundaryAndDiscardsStalePalette(t *testing.T) {
1052- input := newChunkedBlockingReader(
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"),
1056- )
1057- defer input.unblock()
1058-
1059- var out bytes.Buffer
1060- var restoreCount atomic.Int32
1061- resizeCh := make(chan domain.Size)
1062- tm := portsmocks.NewMockTerminal(t)
1063- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1064- tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
1065- tm.EXPECT().In().Return(input).Maybe()
1066- tm.EXPECT().Out().Return(&out).Maybe()
1067- tm.EXPECT().Flush().Return(nil).Maybe()
1068- tm.EXPECT().QueryColors().Return(nil).Once()
1069- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1070-
1071- var inputBytes []byte
1072- var inputMu sync.Mutex
1073- themes := make(chan ports.Theme, 2)
1074- freshTheme := make(chan struct{})
1075- var freshOnce sync.Once
1076- tr := portsmocks.NewMockTransport(t)
1077- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1078- tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
1079- in, err := ports.UnmarshalInput(f.Payload)
1080- if err != nil {
1081- return err
1082- }
1083- inputMu.Lock()
1084- inputBytes = append(inputBytes, in.Data...)
1085- inputMu.Unlock()
1086- return nil
1087- }).Maybe()
1088- tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
1089- theme, err := ports.UnmarshalTheme(f.Payload)
1090- if err != nil {
1091- return err
1092- }
1093- themes <- theme
1094- if theme.PaletteKnown == 1<<3 {
1095- freshOnce.Do(func() { close(freshTheme) })
1096- }
1097- return nil
1098- }).Times(2)
1099- var welcomed atomic.Bool
1100- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
1101- if welcomed.CompareAndSwap(false, true) {
1102- return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})), nil
1103- }
1104- <-freshTheme
1105- return frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})), nil
1106- }).Maybe()
1107- tr.EXPECT().Close().Return(nil).Once()
1108-
1109- require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
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())
1112-
1113- cleared := <-themes
1114- fresh := <-themes
1115- require.True(t, cleared.SchemeKnown)
1116- require.True(t, cleared.Light)
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 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")
1120- inputMu.Lock()
1121- require.Equal(t, []byte("a\x1b[0nbcd"), inputBytes, "the old status response and ordinary input around the boundary must survive")
1122- inputMu.Unlock()
1123-}
1124-
1125-func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) {
1126- var out bytes.Buffer
1127- var restoreCount atomic.Int32
1128- resizeCh := make(chan domain.Size)
1129- input := newChunkedBlockingReader(
1130- []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"),
1131- []byte("\x1b[?997;2n"),
1132- []byte("\x1b[?2031;1$y"),
1133- )
1134- defer input.unblock()
1135-
1136- tm := portsmocks.NewMockTerminal(t)
1137- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1138- tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
1139- tm.EXPECT().In().Return(input).Maybe()
1140- tm.EXPECT().Out().Return(&out).Maybe()
1141- tm.EXPECT().Flush().Return(nil).Maybe()
1142- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1143- tm.EXPECT().QueryColors().Return(nil).Once()
1144-
1145- var themes []ports.Theme
1146- var themesMu sync.Mutex
1147- clearedPalette := make(chan struct{})
1148- var clearOnce sync.Once
1149- tr := portsmocks.NewMockTransport(t)
1150- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1151- tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
1152- theme, err := ports.UnmarshalTheme(f.Payload)
1153- if err != nil {
1154- t.Errorf("decode sent theme: %v", err)
1155- return err
1156- }
1157- themesMu.Lock()
1158- themes = append(themes, theme)
1159- themesMu.Unlock()
1160- if theme.SchemeKnown && theme.PaletteKnown == 0 {
1161- clearOnce.Do(func() { close(clearedPalette) })
1162- }
1163- return nil
1164- }).Times(2)
1165-
1166- welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
1167- detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
1168- closed := make(chan struct{})
1169- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
1170- select {
1171- case <-closed:
1172- return ports.Frame{}, io.EOF
1173- default:
1174- }
1175- select {
1176- case <-clearedPalette:
1177- close(closed)
1178- return detached, nil
1179- default:
1180- return welcome, nil
1181- }
1182- }).Maybe()
1183- tr.EXPECT().Close().Return(nil).Once()
1184-
1185- require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
1186- require.Equal(t, int32(1), restoreCount.Load())
1187-
1188- themesMu.Lock()
1189- defer themesMu.Unlock()
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)
1192- require.True(t, themes[0].HasBackground)
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)
1195- require.True(t, themes[1].SchemeKnown)
1196- require.True(t, themes[1].Light)
1197-}
@@ -230,6 +230,8 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) {
230 closed := make(chan struct{})230 closed := make(chan struct{})
231 operationExited := make(chan struct{})231 operationExited := make(chan struct{})
232 tr := portsmocks.NewMockTransport(t)232 tr := portsmocks.NewMockTransport(t)
233+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
234+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
233 tr.EXPECT().Close().Run(func() { close(closed) }).Return(nil).Once()235 tr.EXPECT().Close().Run(func() { close(closed) }).Return(nil).Once()
234 236
235 block := func() error {237 block := func() error {
@@ -308,6 +310,8 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) {
308 310
309 gotHello := make(chan ports.Hello, 1)311 gotHello := make(chan ports.Hello, 1)
310 tr := portsmocks.NewMockTransport(t)312 tr := portsmocks.NewMockTransport(t)
313+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
314+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
311 tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {315 tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
312 hello, err := ports.UnmarshalHello(f.Payload)316 hello, err := ports.UnmarshalHello(f.Payload)
313 require.NoError(t, err)317 require.NoError(t, err)
@@ -345,6 +349,8 @@ func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) {
345 349
346 gotHello := make(chan ports.Hello, 1)350 gotHello := make(chan ports.Hello, 1)
347 tr := portsmocks.NewMockTransport(t)351 tr := portsmocks.NewMockTransport(t)
352+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
353+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
348 tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {354 tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
349 hello, err := ports.UnmarshalHello(f.Payload)355 hello, err := ports.UnmarshalHello(f.Payload)
350 if err != nil {356 if err != nil {
@@ -372,6 +378,8 @@ func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) {
372 defer in.unblock()378 defer in.unblock()
373 379
374 tr := portsmocks.NewMockTransport(t)380 tr := portsmocks.NewMockTransport(t)
381+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
382+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
375 tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {383 tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error {
376 hello, err := ports.UnmarshalHello(f.Payload)384 hello, err := ports.UnmarshalHello(f.Payload)
377 require.NoError(t, err)385 require.NoError(t, err)
@@ -396,6 +404,8 @@ func TestAttachHappyPath(t *testing.T) {
396 defer in.unblock()404 defer in.unblock()
397 405
398 tr := portsmocks.NewMockTransport(t)406 tr := portsmocks.NewMockTransport(t)
407+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
408+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
399 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()409 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
400 unblock := scriptRecv(tr,410 unblock := scriptRecv(tr,
401 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main"}))},411 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1", SessionName: "main"}))},
@@ -407,7 +417,7 @@ func TestAttachHappyPath(t *testing.T) {
407 417
408 err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})418 err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
409 require.NoError(t, err)419 require.NoError(t, err)
410- require.Equal(t, "hello world", out.String())420+ require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07\x1b[?2031$phello world", out.String())
411 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")421 require.Equal(t, int32(1), restoreCount.Load(), "restore must run exactly once")
412 }422 }
413 423
@@ -417,6 +427,8 @@ func TestAttachVersionMismatch(t *testing.T) {
417 // EnterRaw must NOT be called on the error-before-welcome path.427 // EnterRaw must NOT be called on the error-before-welcome path.
418 428
419 tr := portsmocks.NewMockTransport(t)429 tr := portsmocks.NewMockTransport(t)
430+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
431+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
420 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()432 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
421 tr.EXPECT().Recv().Return(433 tr.EXPECT().Recv().Return(
422 frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),434 frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),
@@ -439,6 +451,8 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) {
439 defer in.unblock()451 defer in.unblock()
440 452
441 tr := portsmocks.NewMockTransport(t)453 tr := portsmocks.NewMockTransport(t)
454+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
455+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
442 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()456 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
443 boom := errors.New("connection reset")457 boom := errors.New("connection reset")
444 unblock := scriptRecv(tr,458 unblock := scriptRecv(tr,
@@ -461,6 +475,8 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
461 defer in.unblock()475 defer in.unblock()
462 476
463 tr := portsmocks.NewMockTransport(t)477 tr := portsmocks.NewMockTransport(t)
478+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
479+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
464 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()480 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
465 unblock := scriptRecv(tr,481 unblock := scriptRecv(tr,
466 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},482 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
@@ -475,221 +491,6 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) {
475 require.Equal(t, int32(1), restoreCount.Load())491 require.Equal(t, int32(1), restoreCount.Load())
476 }492 }
477 493
478-func TestAttachStdinOSCColorResponseSendsThemeAndPreservesInput(t *testing.T) {
479- t.Setenv("COLORTERM", "truecolor")
480-
481- var out bytes.Buffer
482- var restoreCount atomic.Int32
483- resizeCh := make(chan domain.Size)
484- input := newOneShotBlockingReader([]byte("a\x1b]11;rgb:0101/0202/0303\x07b"))
485- defer input.unblock()
486-
487- tm := portsmocks.NewMockTerminal(t)
488- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
489- tm.EXPECT().EnterRaw().Return(func() error {
490- restoreCount.Add(1)
491- return nil
492- }, nil).Once()
493- tm.EXPECT().In().Return(input).Maybe()
494- tm.EXPECT().Out().Return(&out).Maybe()
495- tm.EXPECT().Flush().Return(nil).Maybe()
496- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
497-
498- gotTheme := make(chan ports.Theme, 1)
499- gotInput := make(chan []byte, 2)
500- allowDetach := make(chan struct{})
501-
502- tr := portsmocks.NewMockTransport(t)
503- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
504- tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
505- in, err := ports.UnmarshalInput(f.Payload)
506- require.NoError(t, err)
507- require.NotContains(t, string(in.Data), "\x1b]11;")
508- gotInput <- append([]byte(nil), in.Data...)
509- if bytes.Contains(in.Data, []byte("b")) {
510- close(allowDetach)
511- }
512- return nil
513- }).Maybe()
514- tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
515- th, err := ports.UnmarshalTheme(f.Payload)
516- require.NoError(t, err)
517- gotTheme <- th
518- return nil
519- }).Once()
520-
521- welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
522- detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
523- recvCh := make(chan recvItem, 1)
524- recvCh <- recvItem{f: welcome}
525- closed := make(chan struct{})
526- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
527- select {
528- case it := <-recvCh:
529- return it.f, it.err
530- case <-allowDetach:
531- select {
532- case <-closed:
533- return ports.Frame{}, io.EOF
534- default:
535- close(closed)
536- return detached, nil
537- }
538- case <-closed:
539- return ports.Frame{}, io.EOF
540- }
541- }).Maybe()
542- tr.EXPECT().Close().Return(nil).Once()
543-
544- err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
545- require.NoError(t, err)
546- require.Equal(t, int32(1), restoreCount.Load())
547-
548- select {
549- case th := <-gotTheme:
550- require.True(t, th.HasBackground)
551- require.Equal(t, uint8(1), th.Background.R)
552- require.Equal(t, uint8(2), th.Background.G)
553- require.Equal(t, uint8(3), th.Background.B)
554- require.True(t, th.TrueColor)
555- case <-time.After(2 * time.Second):
556- t.Fatal("theme frame was not sent")
557- }
558-
559- var inputBytes []byte
560- for {
561- select {
562- case b := <-gotInput:
563- inputBytes = append(inputBytes, b...)
564- default:
565- require.Equal(t, []byte("ab"), inputBytes)
566- return
567- }
568- }
569-}
570-
571-func TestAttachStdinThemeTrueColorFalseWhenCOLORTERMNotTruecolor(t *testing.T) {
572- t.Setenv("TERM", "xterm-256color")
573- t.Setenv("COLORTERM", "")
574-
575- var out bytes.Buffer
576- var restoreCount atomic.Int32
577- resizeCh := make(chan domain.Size)
578- input := newOneShotBlockingReader([]byte("\x1b]10;#010203\x07"))
579- defer input.unblock()
580-
581- tm := portsmocks.NewMockTerminal(t)
582- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
583- tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
584- tm.EXPECT().In().Return(input).Maybe()
585- tm.EXPECT().Out().Return(&out).Maybe()
586- tm.EXPECT().Flush().Return(nil).Maybe()
587- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
588-
589- gotTheme := make(chan ports.Theme, 1)
590- allowDetach := make(chan struct{})
591- tr := portsmocks.NewMockTransport(t)
592- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
593- tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
594- th, err := ports.UnmarshalTheme(f.Payload)
595- require.NoError(t, err)
596- gotTheme <- th
597- close(allowDetach)
598- return nil
599- }).Once()
600- welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
601- detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
602- recvCh := make(chan recvItem, 1)
603- recvCh <- recvItem{f: welcome}
604- closed := make(chan struct{})
605- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
606- select {
607- case it := <-recvCh:
608- return it.f, it.err
609- case <-allowDetach:
610- select {
611- case <-closed:
612- return ports.Frame{}, io.EOF
613- default:
614- close(closed)
615- return detached, nil
616- }
617- case <-closed:
618- return ports.Frame{}, io.EOF
619- }
620- }).Maybe()
621- tr.EXPECT().Close().Return(nil).Once()
622-
623- err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
624- require.NoError(t, err)
625- select {
626- case th := <-gotTheme:
627- require.True(t, th.HasForeground)
628- require.False(t, th.TrueColor)
629- case <-time.After(2 * time.Second):
630- t.Fatal("theme frame was not sent")
631- }
632-}
633-
634-func TestAttachForwardsStandaloneEscapeInput(t *testing.T) {
635- var out bytes.Buffer
636- var restoreCount atomic.Int32
637- resizeCh := make(chan domain.Size)
638- input := newOneShotBlockingReader([]byte("\x1b"))
639- defer input.unblock()
640-
641- tm := portsmocks.NewMockTerminal(t)
642- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
643- tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
644- tm.EXPECT().In().Return(input).Maybe()
645- tm.EXPECT().Out().Return(&out).Maybe()
646- tm.EXPECT().Flush().Return(nil).Maybe()
647- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
648-
649- gotInput := make(chan []byte, 1)
650- allowDetach := make(chan struct{})
651- tr := portsmocks.NewMockTransport(t)
652- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
653- tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
654- in, err := ports.UnmarshalInput(f.Payload)
655- require.NoError(t, err)
656- gotInput <- in.Data
657- close(allowDetach)
658- return nil
659- }).Once()
660- welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
661- detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
662- recvCh := make(chan recvItem, 1)
663- recvCh <- recvItem{f: welcome}
664- closed := make(chan struct{})
665- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
666- select {
667- case it := <-recvCh:
668- return it.f, it.err
669- case <-allowDetach:
670- select {
671- case <-closed:
672- return ports.Frame{}, io.EOF
673- default:
674- close(closed)
675- return detached, nil
676- }
677- case <-closed:
678- return ports.Frame{}, io.EOF
679- }
680- }).Maybe()
681- tr.EXPECT().Close().Return(nil).Once()
682-
683- err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""})
684- require.NoError(t, err)
685- select {
686- case got := <-gotInput:
687- require.Equal(t, []byte("\x1b"), got)
688- case <-time.After(2 * time.Second):
689- t.Fatal("standalone escape input was not sent")
690- }
691-}
692-
693 func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {494 func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
694 var out bytes.Buffer495 var out bytes.Buffer
695 var restoreCount atomic.Int32496 var restoreCount atomic.Int32
@@ -708,6 +509,8 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) {
708 gotInput := make(chan []byte, 2)509 gotInput := make(chan []byte, 2)
709 allowDetach := make(chan struct{})510 allowDetach := make(chan struct{})
710 tr := portsmocks.NewMockTransport(t)511 tr := portsmocks.NewMockTransport(t)
512+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
513+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
711 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()514 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
712 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {515 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
713 in, err := ports.UnmarshalInput(f.Payload)516 in, err := ports.UnmarshalInput(f.Payload)
@@ -768,6 +571,8 @@ func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) {
768 gotInput := make(chan []byte, 1)571 gotInput := make(chan []byte, 1)
769 allowDetach := make(chan struct{})572 allowDetach := make(chan struct{})
770 tr := portsmocks.NewMockTransport(t)573 tr := portsmocks.NewMockTransport(t)
574+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
575+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
771 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()576 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
772 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {577 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
773 in, err := ports.UnmarshalInput(f.Payload)578 in, err := ports.UnmarshalInput(f.Payload)
@@ -821,6 +626,8 @@ func TestAttachForwardsResize(t *testing.T) {
821 var firstRecvOnce sync.Once626 var firstRecvOnce sync.Once
822 627
823 tr := portsmocks.NewMockTransport(t)628 tr := portsmocks.NewMockTransport(t)
629+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
630+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
824 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()631 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
825 // The resize frame is forwarded via the sender goroutine.632 // The resize frame is forwarded via the sender goroutine.
826 gotResize := make(chan ports.Resize, 1)633 gotResize := make(chan ports.Resize, 1)
@@ -880,6 +687,8 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) {
880 defer in.unblock()687 defer in.unblock()
881 688
882 tr := portsmocks.NewMockTransport(t)689 tr := portsmocks.NewMockTransport(t)
690+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
691+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
883 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()692 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
884 boom := errors.New("connection reset")693 boom := errors.New("connection reset")
885 unblock := scriptRecv(tr,694 unblock := scriptRecv(tr,
@@ -902,6 +711,8 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) {
902 // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.711 // EnterRaw must NOT be called when the daemon rejects Hello before Welcome.
903 712
904 tr := portsmocks.NewMockTransport(t)713 tr := portsmocks.NewMockTransport(t)
714+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
715+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
905 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()716 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
906 tr.EXPECT().Recv().Return(717 tr.EXPECT().Recv().Return(
907 frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),718 frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})),
@@ -987,7 +798,6 @@ func (t *runTerminal) EnterRaw() (func() error, error) {
987 }798 }
988 func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }799 func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil }
989 func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }800 func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh }
990-func (t *runTerminal) QueryColors() error { return nil }
991 func (t *runTerminal) In() io.Reader { return t.in }801 func (t *runTerminal) In() io.Reader { return t.in }
992 func (t *runTerminal) Out() io.Writer { return &t.out }802 func (t *runTerminal) Out() io.Writer { return &t.out }
993 func (t *runTerminal) Flush() error { return nil }803 func (t *runTerminal) Flush() error { return nil }
@@ -1047,151 +857,3 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) {
1047 require.Equal(t, int32(1), d.calls.Load())857 require.Equal(t, int32(1), d.calls.Load())
1048 require.Equal(t, int32(1), term.restoreCount.Load())858 require.Equal(t, int32(1), term.restoreCount.Load())
1049 }859 }
1050-
1051-func TestAttachSchemeRequeryCorrelatesSplitBoundaryAndDiscardsStalePalette(t *testing.T) {
1052- input := newChunkedBlockingReader(
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"),
1056- )
1057- defer input.unblock()
1058-
1059- var out bytes.Buffer
1060- var restoreCount atomic.Int32
1061- resizeCh := make(chan domain.Size)
1062- tm := portsmocks.NewMockTerminal(t)
1063- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1064- tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
1065- tm.EXPECT().In().Return(input).Maybe()
1066- tm.EXPECT().Out().Return(&out).Maybe()
1067- tm.EXPECT().Flush().Return(nil).Maybe()
1068- tm.EXPECT().QueryColors().Return(nil).Once()
1069- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1070-
1071- var inputBytes []byte
1072- var inputMu sync.Mutex
1073- themes := make(chan ports.Theme, 2)
1074- freshTheme := make(chan struct{})
1075- var freshOnce sync.Once
1076- tr := portsmocks.NewMockTransport(t)
1077- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1078- tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
1079- in, err := ports.UnmarshalInput(f.Payload)
1080- if err != nil {
1081- return err
1082- }
1083- inputMu.Lock()
1084- inputBytes = append(inputBytes, in.Data...)
1085- inputMu.Unlock()
1086- return nil
1087- }).Maybe()
1088- tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
1089- theme, err := ports.UnmarshalTheme(f.Payload)
1090- if err != nil {
1091- return err
1092- }
1093- themes <- theme
1094- if theme.PaletteKnown == 1<<3 {
1095- freshOnce.Do(func() { close(freshTheme) })
1096- }
1097- return nil
1098- }).Times(2)
1099- var welcomed atomic.Bool
1100- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
1101- if welcomed.CompareAndSwap(false, true) {
1102- return frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"})), nil
1103- }
1104- <-freshTheme
1105- return frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})), nil
1106- }).Maybe()
1107- tr.EXPECT().Close().Return(nil).Once()
1108-
1109- require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
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())
1112-
1113- cleared := <-themes
1114- fresh := <-themes
1115- require.True(t, cleared.SchemeKnown)
1116- require.True(t, cleared.Light)
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 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")
1120- inputMu.Lock()
1121- require.Equal(t, []byte("a\x1b[0nbcd"), inputBytes, "the old status response and ordinary input around the boundary must survive")
1122- inputMu.Unlock()
1123-}
1124-
1125-func TestAttachSchemeRequeryClearsStalePalette(t *testing.T) {
1126- var out bytes.Buffer
1127- var restoreCount atomic.Int32
1128- resizeCh := make(chan domain.Size)
1129- input := newChunkedBlockingReader(
1130- []byte("\x1b]11;#010203\a\x1b]4;1;#112233\a\x1b]4;14;#778899\a"),
1131- []byte("\x1b[?997;2n"),
1132- []byte("\x1b[?2031;1$y"),
1133- )
1134- defer input.unblock()
1135-
1136- tm := portsmocks.NewMockTerminal(t)
1137- tm.EXPECT().Size().Return(domain.Size{Cols: 80, Rows: 24}, nil).Once()
1138- tm.EXPECT().EnterRaw().Return(func() error { restoreCount.Add(1); return nil }, nil).Once()
1139- tm.EXPECT().In().Return(input).Maybe()
1140- tm.EXPECT().Out().Return(&out).Maybe()
1141- tm.EXPECT().Flush().Return(nil).Maybe()
1142- tm.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
1143- tm.EXPECT().QueryColors().Return(nil).Once()
1144-
1145- var themes []ports.Theme
1146- var themesMu sync.Mutex
1147- clearedPalette := make(chan struct{})
1148- var clearOnce sync.Once
1149- tr := portsmocks.NewMockTransport(t)
1150- tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
1151- tr.EXPECT().Send(isType(ports.MsgTheme)).RunAndReturn(func(f ports.Frame) error {
1152- theme, err := ports.UnmarshalTheme(f.Payload)
1153- if err != nil {
1154- t.Errorf("decode sent theme: %v", err)
1155- return err
1156- }
1157- themesMu.Lock()
1158- themes = append(themes, theme)
1159- themesMu.Unlock()
1160- if theme.SchemeKnown && theme.PaletteKnown == 0 {
1161- clearOnce.Do(func() { close(clearedPalette) })
1162- }
1163- return nil
1164- }).Times(2)
1165-
1166- welcome := frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))
1167- detached := frameOf(ports.MsgDetached, ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach}))
1168- closed := make(chan struct{})
1169- tr.EXPECT().Recv().RunAndReturn(func() (ports.Frame, error) {
1170- select {
1171- case <-closed:
1172- return ports.Frame{}, io.EOF
1173- default:
1174- }
1175- select {
1176- case <-clearedPalette:
1177- close(closed)
1178- return detached, nil
1179- default:
1180- return welcome, nil
1181- }
1182- }).Maybe()
1183- tr.EXPECT().Close().Return(nil).Once()
1184-
1185- require.NoError(t, runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral}))
1186- require.Equal(t, int32(1), restoreCount.Load())
1187-
1188- themesMu.Lock()
1189- defer themesMu.Unlock()
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)
1192- require.True(t, themes[0].HasBackground)
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)
1195- require.True(t, themes[1].SchemeKnown)
1196- require.True(t, themes[1].Light)
1197-}
M internal/usecase/client/clipboard_intercept_test.go +1 -0
@@ -42,6 +42,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
4242 allowDetach := make(chan struct{})
4343
4444 tr := portsmocks.NewMockTransport(t)
45+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
4546 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
4647 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
4748 in, err := ports.UnmarshalInput(f.Payload)
@@ -42,6 +42,7 @@ func runRemoteClipboardTest(t *testing.T, remote bool, clip ports.ClipboardReade
42 allowDetach := make(chan struct{})42 allowDetach := make(chan struct{})
43 43
44 tr := portsmocks.NewMockTransport(t)44 tr := portsmocks.NewMockTransport(t)
45+ tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
45 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()46 tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
46 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {47 tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error {
47 in, err := ports.UnmarshalInput(f.Payload)48 in, err := ports.UnmarshalInput(f.Payload)
A internal/usecase/client/palette_attach_test.go +131 -0
@@ -0,0 +1,131 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "log/slog"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/pkg/renderer"
17+)
18+
19+type paletteAttachClock struct{}
20+
21+func (paletteAttachClock) Now() time.Time { return time.Time{} }
22+func (paletteAttachClock) NewTimer(time.Duration) ports.Timer {
23+ return paletteAttachTimer{ch: make(chan time.Time)}
24+}
25+
26+type paletteAttachTimer struct{ ch chan time.Time }
27+
28+func (t paletteAttachTimer) C() <-chan time.Time { return t.ch }
29+func (paletteAttachTimer) Reset(time.Duration) bool { return false }
30+func (paletteAttachTimer) Stop() bool { return true }
31+
32+type paletteAttachReader struct {
33+ data []byte
34+ once sync.Once
35+}
36+
37+func (r *paletteAttachReader) Read(p []byte) (int, error) {
38+ read := false
39+ r.once.Do(func() { read = true })
40+ if read {
41+ return copy(p, r.data), nil
42+ }
43+ select {}
44+}
45+
46+type paletteAttachTerminal struct {
47+ in io.Reader
48+ out bytes.Buffer
49+ resize chan domain.Size
50+}
51+
52+func (t *paletteAttachTerminal) EnterRaw() (func() error, error) {
53+ return func() error { return nil }, nil
54+}
55+func (t *paletteAttachTerminal) Size() (domain.Size, error) {
56+ return domain.Size{Cols: 80, Rows: 24}, nil
57+}
58+func (t *paletteAttachTerminal) ResizeEvents() <-chan domain.Size { return t.resize }
59+func (t *paletteAttachTerminal) In() io.Reader { return t.in }
60+func (t *paletteAttachTerminal) Out() io.Writer { return &t.out }
61+func (t *paletteAttachTerminal) Flush() error { return nil }
62+
63+type paletteAttachTransport struct {
64+ mu sync.Mutex
65+ sent []ports.Frame
66+ themes []ports.Theme
67+ finals int
68+ finalSet chan struct{}
69+}
70+
71+func (t *paletteAttachTransport) Send(frame ports.Frame) error {
72+ t.mu.Lock()
73+ defer t.mu.Unlock()
74+ t.sent = append(t.sent, frame)
75+ if frame.Type == ports.MsgTheme {
76+ theme, err := ports.UnmarshalTheme(frame.Payload)
77+ if err != nil {
78+ return err
79+ }
80+ t.themes = append(t.themes, theme)
81+ if theme.HasForeground && theme.HasBackground {
82+ t.finals++
83+ if t.finals == 1 {
84+ close(t.finalSet)
85+ }
86+ }
87+ }
88+ return nil
89+}
90+
91+func (t *paletteAttachTransport) Recv() (ports.Frame, error) {
92+ t.mu.Lock()
93+ first := len(t.sent) == 1
94+ t.mu.Unlock()
95+ if first {
96+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s"})}, nil
97+ }
98+ <-t.finalSet
99+ return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}, nil
100+}
101+func (*paletteAttachTransport) Close() error { return nil }
102+
103+func TestAttachPublishesOnlyClearedAndDefinitiveInitialPalette(t *testing.T) {
104+ input := &paletteAttachReader{data: []byte("\x1b]10;#010203\a\x1b]11;#040506\a\x1b]4;2;#102030\a\x1b[?2031;1$y")}
105+ term := &paletteAttachTerminal{in: input, resize: make(chan domain.Size)}
106+ transport := &paletteAttachTransport{finalSet: make(chan struct{})}
107+ ms := &milestones{}
108+ runner := &Runner{term: term, clock: paletteAttachClock{}, logger: slog.New(slog.DiscardHandler)}
109+ attempt := &attachAttempt{
110+ runner: runner, transport: transport, request: AttachRequest{Intent: ports.IntentAttach},
111+ milestones: ms, themeState: &terminalThemeState{},
112+ enterRaw: func() error { ms.rawEntered = true; return nil },
113+ reconnect: &reconnectUI{term: term, rawEntered: new(bool)},
114+ }
115+
116+ result := attempt.run(context.Background())
117+ require.NoError(t, result.err)
118+ require.Equal(t, paletteColorBatch, term.out.String())
119+
120+ transport.mu.Lock()
121+ defer transport.mu.Unlock()
122+ require.Len(t, transport.themes, 2)
123+ cleared, definitive := transport.themes[0], transport.themes[1]
124+ require.Zero(t, cleared.PaletteKnown)
125+ require.False(t, cleared.HasForeground)
126+ require.True(t, definitive.HasForeground)
127+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, definitive.Foreground)
128+ require.True(t, definitive.HasBackground)
129+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, definitive.Background)
130+ require.Equal(t, uint16(1<<2), definitive.PaletteKnown)
131+}
@@ -0,0 +1,131 @@
1+package client
2+
3+import (
4+ "bytes"
5+ "context"
6+ "io"
7+ "log/slog"
8+ "sync"
9+ "testing"
10+ "time"
11+
12+ "github.com/stretchr/testify/require"
13+
14+ "github.com/bnema/vev/internal/domain"
15+ "github.com/bnema/vev/internal/ports"
16+ "github.com/bnema/vev/pkg/renderer"
17+)
18+
19+type paletteAttachClock struct{}
20+
21+func (paletteAttachClock) Now() time.Time { return time.Time{} }
22+func (paletteAttachClock) NewTimer(time.Duration) ports.Timer {
23+ return paletteAttachTimer{ch: make(chan time.Time)}
24+}
25+
26+type paletteAttachTimer struct{ ch chan time.Time }
27+
28+func (t paletteAttachTimer) C() <-chan time.Time { return t.ch }
29+func (paletteAttachTimer) Reset(time.Duration) bool { return false }
30+func (paletteAttachTimer) Stop() bool { return true }
31+
32+type paletteAttachReader struct {
33+ data []byte
34+ once sync.Once
35+}
36+
37+func (r *paletteAttachReader) Read(p []byte) (int, error) {
38+ read := false
39+ r.once.Do(func() { read = true })
40+ if read {
41+ return copy(p, r.data), nil
42+ }
43+ select {}
44+}
45+
46+type paletteAttachTerminal struct {
47+ in io.Reader
48+ out bytes.Buffer
49+ resize chan domain.Size
50+}
51+
52+func (t *paletteAttachTerminal) EnterRaw() (func() error, error) {
53+ return func() error { return nil }, nil
54+}
55+func (t *paletteAttachTerminal) Size() (domain.Size, error) {
56+ return domain.Size{Cols: 80, Rows: 24}, nil
57+}
58+func (t *paletteAttachTerminal) ResizeEvents() <-chan domain.Size { return t.resize }
59+func (t *paletteAttachTerminal) In() io.Reader { return t.in }
60+func (t *paletteAttachTerminal) Out() io.Writer { return &t.out }
61+func (t *paletteAttachTerminal) Flush() error { return nil }
62+
63+type paletteAttachTransport struct {
64+ mu sync.Mutex
65+ sent []ports.Frame
66+ themes []ports.Theme
67+ finals int
68+ finalSet chan struct{}
69+}
70+
71+func (t *paletteAttachTransport) Send(frame ports.Frame) error {
72+ t.mu.Lock()
73+ defer t.mu.Unlock()
74+ t.sent = append(t.sent, frame)
75+ if frame.Type == ports.MsgTheme {
76+ theme, err := ports.UnmarshalTheme(frame.Payload)
77+ if err != nil {
78+ return err
79+ }
80+ t.themes = append(t.themes, theme)
81+ if theme.HasForeground && theme.HasBackground {
82+ t.finals++
83+ if t.finals == 1 {
84+ close(t.finalSet)
85+ }
86+ }
87+ }
88+ return nil
89+}
90+
91+func (t *paletteAttachTransport) Recv() (ports.Frame, error) {
92+ t.mu.Lock()
93+ first := len(t.sent) == 1
94+ t.mu.Unlock()
95+ if first {
96+ return ports.Frame{Type: ports.MsgWelcome, Payload: ports.MarshalWelcome(ports.Welcome{SessionID: "s"})}, nil
97+ }
98+ <-t.finalSet
99+ return ports.Frame{Type: ports.MsgDetached, Payload: ports.MarshalDetached(ports.Detached{Reason: ports.ReasonDetach})}, nil
100+}
101+func (*paletteAttachTransport) Close() error { return nil }
102+
103+func TestAttachPublishesOnlyClearedAndDefinitiveInitialPalette(t *testing.T) {
104+ input := &paletteAttachReader{data: []byte("\x1b]10;#010203\a\x1b]11;#040506\a\x1b]4;2;#102030\a\x1b[?2031;1$y")}
105+ term := &paletteAttachTerminal{in: input, resize: make(chan domain.Size)}
106+ transport := &paletteAttachTransport{finalSet: make(chan struct{})}
107+ ms := &milestones{}
108+ runner := &Runner{term: term, clock: paletteAttachClock{}, logger: slog.New(slog.DiscardHandler)}
109+ attempt := &attachAttempt{
110+ runner: runner, transport: transport, request: AttachRequest{Intent: ports.IntentAttach},
111+ milestones: ms, themeState: &terminalThemeState{},
112+ enterRaw: func() error { ms.rawEntered = true; return nil },
113+ reconnect: &reconnectUI{term: term, rawEntered: new(bool)},
114+ }
115+
116+ result := attempt.run(context.Background())
117+ require.NoError(t, result.err)
118+ require.Equal(t, paletteColorBatch, term.out.String())
119+
120+ transport.mu.Lock()
121+ defer transport.mu.Unlock()
122+ require.Len(t, transport.themes, 2)
123+ cleared, definitive := transport.themes[0], transport.themes[1]
124+ require.Zero(t, cleared.PaletteKnown)
125+ require.False(t, cleared.HasForeground)
126+ require.True(t, definitive.HasForeground)
127+ require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, definitive.Foreground)
128+ require.True(t, definitive.HasBackground)
129+ require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, definitive.Background)
130+ require.Equal(t, uint16(1<<2), definitive.PaletteKnown)
131+}
M internal/usecase/client/palette_generation.go +34 -27
@@ -46,13 +46,15 @@ const (
4646 paletteEventForeground
4747 paletteEventBackground
4848 paletteEventPalette
49+ paletteEventScheme
4950 )
5051
5152 type paletteGenerationEvent struct {
52- id paletteGenerationID
53- kind paletteGenerationEventKind
54- rgb renderer.RGB
55- slot uint8
53+ id paletteGenerationID
54+ kind paletteGenerationEventKind
55+ rgb renderer.RGB
56+ slot uint8
57+ light bool
5658 }
5759
5860 // paletteGenerationAction is deliberately declarative. The attach loop is
@@ -214,6 +216,7 @@ func (c *paletteGenerationCoordinator) finalize() []paletteGenerationAction {
214216 c.active = false
215217 c.finalized = c.current.theme
216218 return []paletteGenerationAction{
219+ {kind: paletteActionCancelDrainDeadline, id: c.current.id},
217220 {kind: paletteActionCancelCompletionDeadline, id: c.current.id},
218221 {kind: paletteActionPublishFinal, id: c.current.id, theme: c.finalized},
219222 }
@@ -250,37 +253,41 @@ var paletteMarkerResponses = [][]byte{
250253 }
251254
252255 func (s *paletteMarkerScanner) scan(data []byte, onBytes func([]byte), onMarker func()) {
253- for _, b := range data {
256+ for i, b := range data {
254257 s.pending = append(s.pending, b)
255- for len(s.pending) > 0 {
256- matched, possible := false, false
257- for _, response := range paletteMarkerResponses {
258- if len(s.pending) > len(response) {
259- continue
260- }
261- if string(s.pending) != string(response[:len(s.pending)]) {
262- continue
263- }
264- if len(s.pending) == len(response) {
265- matched = true
266- } else {
267- possible = true
268- }
258+ matched, possible := false, false
259+ for _, response := range paletteMarkerResponses {
260+ if len(s.pending) > len(response) || string(s.pending) != string(response[:len(s.pending)]) {
261+ continue
269262 }
270- if matched {
271- s.pending = nil
272- onMarker()
273- break
274- }
275- if possible {
276- break
263+ if len(s.pending) == len(response) {
264+ matched = true
265+ } else {
266+ possible = true
277267 }
268+ }
269+ if matched {
270+ s.pending = nil
271+ onMarker()
272+ continue
273+ }
274+ if possible {
275+ continue
276+ }
277+ if s.pending[0] != '\x1b' {
278278 onBytes(s.pending[:1])
279279 s.pending = s.pending[1:]
280+ continue
280281 }
282+ // This is not a marker. Forward the entire remaining read as one
283+ // contiguous input run so CSI mouse reports and escape sequences keep
284+ // their framing.
285+ s.pending = append(s.pending, data[i+1:]...)
286+ onBytes(s.pending)
287+ s.pending = nil
288+ return
281289 }
282290 }
283-
284291 func (s *paletteMarkerScanner) flush(onBytes func([]byte)) {
285292 if len(s.pending) == 0 {
286293 return
@@ -46,13 +46,15 @@ const (
46 paletteEventForeground46 paletteEventForeground
47 paletteEventBackground47 paletteEventBackground
48 paletteEventPalette48 paletteEventPalette
49+ paletteEventScheme
49 )50 )
50 51
51 type paletteGenerationEvent struct {52 type paletteGenerationEvent struct {
52- id paletteGenerationID53+ id paletteGenerationID
53- kind paletteGenerationEventKind54+ kind paletteGenerationEventKind
54- rgb renderer.RGB55+ rgb renderer.RGB
55- slot uint856+ slot uint8
57+ light bool
56 }58 }
57 59
58 // paletteGenerationAction is deliberately declarative. The attach loop is60 // paletteGenerationAction is deliberately declarative. The attach loop is
@@ -214,6 +216,7 @@ func (c *paletteGenerationCoordinator) finalize() []paletteGenerationAction {
214 c.active = false216 c.active = false
215 c.finalized = c.current.theme217 c.finalized = c.current.theme
216 return []paletteGenerationAction{218 return []paletteGenerationAction{
219+ {kind: paletteActionCancelDrainDeadline, id: c.current.id},
217 {kind: paletteActionCancelCompletionDeadline, id: c.current.id},220 {kind: paletteActionCancelCompletionDeadline, id: c.current.id},
218 {kind: paletteActionPublishFinal, id: c.current.id, theme: c.finalized},221 {kind: paletteActionPublishFinal, id: c.current.id, theme: c.finalized},
219 }222 }
@@ -250,37 +253,41 @@ var paletteMarkerResponses = [][]byte{
250 }253 }
251 254
252 func (s *paletteMarkerScanner) scan(data []byte, onBytes func([]byte), onMarker func()) {255 func (s *paletteMarkerScanner) scan(data []byte, onBytes func([]byte), onMarker func()) {
253- for _, b := range data {256+ for i, b := range data {
254 s.pending = append(s.pending, b)257 s.pending = append(s.pending, b)
255- for len(s.pending) > 0 {258+ matched, possible := false, false
256- matched, possible := false, false259+ for _, response := range paletteMarkerResponses {
257- for _, response := range paletteMarkerResponses {260+ if len(s.pending) > len(response) || string(s.pending) != string(response[:len(s.pending)]) {
258- if len(s.pending) > len(response) {261+ continue
259- continue
260- }
261- if string(s.pending) != string(response[:len(s.pending)]) {
262- continue
263- }
264- if len(s.pending) == len(response) {
265- matched = true
266- } else {
267- possible = true
268- }
269 }262 }
270- if matched {263+ if len(s.pending) == len(response) {
271- s.pending = nil264+ matched = true
272- onMarker()265+ } else {
273- break266+ possible = true
274- }
275- if possible {
276- break
277 }267 }
268+ }
269+ if matched {
270+ s.pending = nil
271+ onMarker()
272+ continue
273+ }
274+ if possible {
275+ continue
276+ }
277+ if s.pending[0] != '\x1b' {
278 onBytes(s.pending[:1])278 onBytes(s.pending[:1])
279 s.pending = s.pending[1:]279 s.pending = s.pending[1:]
280+ continue
280 }281 }
282+ // This is not a marker. Forward the entire remaining read as one
283+ // contiguous input run so CSI mouse reports and escape sequences keep
284+ // their framing.
285+ s.pending = append(s.pending, data[i+1:]...)
286+ onBytes(s.pending)
287+ s.pending = nil
288+ return
281 }289 }
282 }290 }
283-
284 func (s *paletteMarkerScanner) flush(onBytes func([]byte)) {291 func (s *paletteMarkerScanner) flush(onBytes func([]byte)) {
285 if len(s.pending) == 0 {292 if len(s.pending) == 0 {
286 return293 return
M internal/usecase/client/palette_generation_test.go +1 -0
@@ -114,6 +114,7 @@ func TestPaletteGenerationInitialPublishesClearedThenDefinitive(t *testing.T) {
114114 clock.apply(final)
115115
116116 require.Equal(t, []paletteGenerationActionKind{
117+ paletteActionCancelDrainDeadline,
117118 paletteActionCancelCompletionDeadline,
118119 paletteActionPublishFinal,
119120 }, actionKinds(final))
@@ -114,6 +114,7 @@ func TestPaletteGenerationInitialPublishesClearedThenDefinitive(t *testing.T) {
114 clock.apply(final)114 clock.apply(final)
115 115
116 require.Equal(t, []paletteGenerationActionKind{116 require.Equal(t, []paletteGenerationActionKind{
117+ paletteActionCancelDrainDeadline,
117 paletteActionCancelCompletionDeadline,118 paletteActionCancelCompletionDeadline,
118 paletteActionPublishFinal,119 paletteActionPublishFinal,
119 }, actionKinds(final))120 }, actionKinds(final))
M internal/usecase/client/reconnect_toast_test.go +3 -72
@@ -43,7 +43,6 @@ func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarne
4343 h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
4444 h.term.EXPECT().Size().Return(h.size, nil).Maybe()
4545 h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
46- h.term.EXPECT().QueryColors().Return(nil).Maybe()
4746 h.term.EXPECT().In().Return(h.in).Maybe()
4847 h.term.EXPECT().Out().Return(&h.out).Maybe()
4948 h.term.EXPECT().Flush().Return(nil).Maybe()
@@ -63,7 +62,6 @@ func newReconnectToastTerminalHarnessWithOutput(t *testing.T, out io.Writer) *re
6362 h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
6463 h.term.EXPECT().Size().Return(h.size, nil).Maybe()
6564 h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
66- h.term.EXPECT().QueryColors().Return(nil).Maybe()
6765 h.term.EXPECT().In().Return(h.in).Maybe()
6866 h.term.EXPECT().Out().Return(out).Maybe()
6967 h.term.EXPECT().Flush().Return(nil).Maybe()
@@ -115,6 +113,7 @@ func reconnectToastDetach(reason uint8) ports.Frame {
115113 func mockReconnectTransport(t *testing.T, recvs ...reconnectToastRecv) *portsmocks.MockTransport {
116114 t.Helper()
117115 tr := portsmocks.NewMockTransport(t)
116+ tr.EXPECT().Send(mock.MatchedBy(func(f ports.Frame) bool { return f.Type == ports.MsgTheme })).Return(nil).Maybe()
118117 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
119118 for _, recv := range recvs {
120119 tr.EXPECT().Recv().Return(recv.frame, recv.err).Once()
@@ -217,6 +216,8 @@ func newReconnectHandshakeClock(t *testing.T) *portsmocks.MockClock {
217216 timer.EXPECT().C().Return((<-chan time.Time)(make(chan time.Time))).Maybe()
218217 timer.EXPECT().Stop().Return(true).Maybe()
219218 clk.EXPECT().NewTimer(preWelcomeTimeout).Return(timer).Maybe()
219+ // Every successful attach starts an initial palette-generation deadline.
220+ clk.EXPECT().NewTimer(paletteGenerationDeadline).Return(timer).Maybe()
220221 return clk
221222 }
222223
@@ -594,76 +595,6 @@ func (t *blockingThemeRestoreTransport) Close() error {
594595 return nil
595596 }
596597
597-func TestCachedThemeSendDoesNotBlockCancellation(t *testing.T) {
598- term := newReconnectToastTerminalHarness(t)
599- defer term.closeInput()
600- themeState := &terminalThemeState{}
601- themeState.setTrueColor(true)
602- themeState.update(func(theme *ports.Theme) {
603- theme.HasForeground = true
604- theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3}
605- theme.HasBackground = true
606- theme.Background = renderer.RGB{R: 4, G: 5, B: 6}
607- })
608- transport := newBlockingThemeRestoreTransport()
609- ctx, cancel := context.WithCancel(context.Background())
610- resultCh := make(chan attachResult, 1)
611- ms := milestones{}
612- attempt := newReconnectAttachAttempt(term.term, transport, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentResume, SessionName: "main", Remote: true}, 44, themeState, nil, &ms)
613- go func() { resultCh <- attempt.run(ctx) }()
614-
615- select {
616- case <-transport.started:
617- case <-time.After(time.Second):
618- cancel()
619- _ = transport.Close()
620- t.Fatal("cached theme restoration did not start")
621- }
622- cancel()
623- select {
624- case result := <-resultCh:
625- require.True(t, result.welcomed)
626- require.NoError(t, result.err)
627- _ = transport.Close()
628- case <-time.After(100 * time.Millisecond):
629- _ = transport.Close()
630- <-resultCh
631- t.Fatal("cached theme send blocked attach cancellation")
632- }
633-}
634-
635-func TestRemoteReconnectResendsKnownTerminalTheme(t *testing.T) {
636- t.Setenv("TERM", "xterm-256color")
637- t.Setenv("COLORTERM", "truecolor")
638- oldSleep := reconnectSleep
639- oldSleepWithResize := reconnectSleepWithResize
640- reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
641- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
642- return true
643- }
644- defer func() {
645- reconnectSleep = oldSleep
646- reconnectSleepWithResize = oldSleepWithResize
647- }()
648-
649- term := newReconnectToastTerminalHarness(t)
650- defer term.closeInput()
651- go func() {
652- _, _ = term.inWriter.Write([]byte("\x1b]10;#010203\x07\x1b]11;#040506\x07"))
653- }()
654- tr1 := newReconnectThemeTransport(44, true)
655- tr2 := newReconnectThemeTransport(55, false)
656- dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
657-
658- err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
659- require.NoError(t, err)
660- require.True(t, tr2.observedTheme.HasForeground)
661- require.True(t, tr2.observedTheme.HasBackground)
662- require.True(t, tr2.observedTheme.TrueColor)
663- require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, tr2.observedTheme.Foreground)
664- require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, tr2.observedTheme.Background)
665-}
666-
667598 func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
668599 linkDead := errors.New("remote link dead")
669600 oldSleep := reconnectSleep
@@ -43,7 +43,6 @@ func newReconnectToastTerminalHarness(t *testing.T) *reconnectToastTerminalHarne
43 h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()43 h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
44 h.term.EXPECT().Size().Return(h.size, nil).Maybe()44 h.term.EXPECT().Size().Return(h.size, nil).Maybe()
45 h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()45 h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
46- h.term.EXPECT().QueryColors().Return(nil).Maybe()
47 h.term.EXPECT().In().Return(h.in).Maybe()46 h.term.EXPECT().In().Return(h.in).Maybe()
48 h.term.EXPECT().Out().Return(&h.out).Maybe()47 h.term.EXPECT().Out().Return(&h.out).Maybe()
49 h.term.EXPECT().Flush().Return(nil).Maybe()48 h.term.EXPECT().Flush().Return(nil).Maybe()
@@ -63,7 +62,6 @@ func newReconnectToastTerminalHarnessWithOutput(t *testing.T, out io.Writer) *re
63 h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()62 h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe()
64 h.term.EXPECT().Size().Return(h.size, nil).Maybe()63 h.term.EXPECT().Size().Return(h.size, nil).Maybe()
65 h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()64 h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe()
66- h.term.EXPECT().QueryColors().Return(nil).Maybe()
67 h.term.EXPECT().In().Return(h.in).Maybe()65 h.term.EXPECT().In().Return(h.in).Maybe()
68 h.term.EXPECT().Out().Return(out).Maybe()66 h.term.EXPECT().Out().Return(out).Maybe()
69 h.term.EXPECT().Flush().Return(nil).Maybe()67 h.term.EXPECT().Flush().Return(nil).Maybe()
@@ -115,6 +113,7 @@ func reconnectToastDetach(reason uint8) ports.Frame {
115 func mockReconnectTransport(t *testing.T, recvs ...reconnectToastRecv) *portsmocks.MockTransport {113 func mockReconnectTransport(t *testing.T, recvs ...reconnectToastRecv) *portsmocks.MockTransport {
116 t.Helper()114 t.Helper()
117 tr := portsmocks.NewMockTransport(t)115 tr := portsmocks.NewMockTransport(t)
116+ tr.EXPECT().Send(mock.MatchedBy(func(f ports.Frame) bool { return f.Type == ports.MsgTheme })).Return(nil).Maybe()
118 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()117 tr.EXPECT().Send(mock.Anything).Return(nil).Maybe()
119 for _, recv := range recvs {118 for _, recv := range recvs {
120 tr.EXPECT().Recv().Return(recv.frame, recv.err).Once()119 tr.EXPECT().Recv().Return(recv.frame, recv.err).Once()
@@ -217,6 +216,8 @@ func newReconnectHandshakeClock(t *testing.T) *portsmocks.MockClock {
217 timer.EXPECT().C().Return((<-chan time.Time)(make(chan time.Time))).Maybe()216 timer.EXPECT().C().Return((<-chan time.Time)(make(chan time.Time))).Maybe()
218 timer.EXPECT().Stop().Return(true).Maybe()217 timer.EXPECT().Stop().Return(true).Maybe()
219 clk.EXPECT().NewTimer(preWelcomeTimeout).Return(timer).Maybe()218 clk.EXPECT().NewTimer(preWelcomeTimeout).Return(timer).Maybe()
219+ // Every successful attach starts an initial palette-generation deadline.
220+ clk.EXPECT().NewTimer(paletteGenerationDeadline).Return(timer).Maybe()
220 return clk221 return clk
221 }222 }
222 223
@@ -594,76 +595,6 @@ func (t *blockingThemeRestoreTransport) Close() error {
594 return nil595 return nil
595 }596 }
596 597
597-func TestCachedThemeSendDoesNotBlockCancellation(t *testing.T) {
598- term := newReconnectToastTerminalHarness(t)
599- defer term.closeInput()
600- themeState := &terminalThemeState{}
601- themeState.setTrueColor(true)
602- themeState.update(func(theme *ports.Theme) {
603- theme.HasForeground = true
604- theme.Foreground = renderer.RGB{R: 1, G: 2, B: 3}
605- theme.HasBackground = true
606- theme.Background = renderer.RGB{R: 4, G: 5, B: 6}
607- })
608- transport := newBlockingThemeRestoreTransport()
609- ctx, cancel := context.WithCancel(context.Background())
610- resultCh := make(chan attachResult, 1)
611- ms := milestones{}
612- attempt := newReconnectAttachAttempt(term.term, transport, newReconnectHandshakeClock(t), AttachRequest{Intent: ports.IntentResume, SessionName: "main", Remote: true}, 44, themeState, nil, &ms)
613- go func() { resultCh <- attempt.run(ctx) }()
614-
615- select {
616- case <-transport.started:
617- case <-time.After(time.Second):
618- cancel()
619- _ = transport.Close()
620- t.Fatal("cached theme restoration did not start")
621- }
622- cancel()
623- select {
624- case result := <-resultCh:
625- require.True(t, result.welcomed)
626- require.NoError(t, result.err)
627- _ = transport.Close()
628- case <-time.After(100 * time.Millisecond):
629- _ = transport.Close()
630- <-resultCh
631- t.Fatal("cached theme send blocked attach cancellation")
632- }
633-}
634-
635-func TestRemoteReconnectResendsKnownTerminalTheme(t *testing.T) {
636- t.Setenv("TERM", "xterm-256color")
637- t.Setenv("COLORTERM", "truecolor")
638- oldSleep := reconnectSleep
639- oldSleepWithResize := reconnectSleepWithResize
640- reconnectSleep = func(context.Context, ports.Clock, time.Duration) bool { return true }
641- reconnectSleepWithResize = func(context.Context, ports.Clock, time.Duration, <-chan domain.Size, func(domain.Size)) bool {
642- return true
643- }
644- defer func() {
645- reconnectSleep = oldSleep
646- reconnectSleepWithResize = oldSleepWithResize
647- }()
648-
649- term := newReconnectToastTerminalHarness(t)
650- defer term.closeInput()
651- go func() {
652- _, _ = term.inWriter.Write([]byte("\x1b]10;#010203\x07\x1b]11;#040506\x07"))
653- }()
654- tr1 := newReconnectThemeTransport(44, true)
655- tr2 := newReconnectThemeTransport(55, false)
656- dialer := &reconnectToastSequenceDialer{transports: []ports.Transport{tr1, tr2}}
657-
658- err := NewRunner(Dependencies{Dialer: dialer, Terminal: term.term, Clock: newReconnectHandshakeClock(t), Logger: slog.New(slog.DiscardHandler)}).Run(context.Background(), AttachRequest{Intent: ports.IntentAttach, SessionName: "main", Remote: true})
659- require.NoError(t, err)
660- require.True(t, tr2.observedTheme.HasForeground)
661- require.True(t, tr2.observedTheme.HasBackground)
662- require.True(t, tr2.observedTheme.TrueColor)
663- require.Equal(t, renderer.RGB{R: 1, G: 2, B: 3}, tr2.observedTheme.Foreground)
664- require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, tr2.observedTheme.Background)
665-}
666-
667 func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {598 func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) {
668 linkDead := errors.New("remote link dead")599 linkDead := errors.New("remote link dead")
669 oldSleep := reconnectSleep600 oldSleep := reconnectSleep
M internal/usecase/client/theme_state_test.go +0 -248
@@ -1,12 +1,7 @@
11 package client
22
33 import (
4- "bytes"
5- "context"
6- "io"
7- "log/slog"
84 "testing"
9- "time"
105
116 "github.com/stretchr/testify/require"
127
@@ -41,246 +36,3 @@ func TestTerminalThemeStateClearPalettePreservesReportedColors(t *testing.T) {
4136 _, reported := state.reportedTheme()
4237 require.True(t, reported, "foreground/background remain usable after palette invalidation")
4338 }
44-
45-type queryBoundaryReader struct {
46- first, second []byte
47- readSecond chan struct{}
48- allowSecond, close <-chan struct{}
49- reads int
50-}
51-
52-func (r *queryBoundaryReader) Read(p []byte) (int, error) {
53- switch r.reads {
54- case 0:
55- r.reads++
56- return copy(p, r.first), nil
57- case 1:
58- select {
59- case r.readSecond <- struct{}{}:
60- case <-r.close:
61- return 0, io.EOF
62- }
63- select {
64- case <-r.allowSecond:
65- r.reads++
66- return copy(p, r.second), nil
67- case <-r.close:
68- return 0, io.EOF
69- }
70- default:
71- <-r.close
72- return 0, io.EOF
73- }
74-}
75-
76-func TestStdinPumpCoalescesPaletteAndBackgroundThemePerRead(t *testing.T) {
77- state := &terminalThemeState{}
78- ctx, cancel := context.WithCancel(context.Background())
79- defer cancel()
80- out := make(chan ports.Frame, 3)
81- pump := stdinPump{
82- ctx: ctx,
83- cancel: cancel,
84- in: bytes.NewReader([]byte("\x1b]4;1;#112233\a\x1b]11;rgb:0404/0505/0606\a\x1b]4;14;#778899\a")),
85- out: out,
86- themeState: state,
87- logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
88- }
89-
90- pump.run()
91-
92- first := <-out
93- require.Equal(t, ports.MsgTheme, first.Type)
94- theme, err := ports.UnmarshalTheme(first.Payload)
95- require.NoError(t, err)
96- require.True(t, theme.HasBackground)
97- require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, theme.Background)
98- require.Equal(t, uint16(1<<1|1<<14), theme.PaletteKnown)
99- require.Equal(t, renderer.RGB{R: 0x11, G: 0x22, B: 0x33}, theme.Palette[1])
100- require.Equal(t, renderer.RGB{R: 0x77, G: 0x88, B: 0x99}, theme.Palette[14])
101-
102- second := <-out
103- require.Equal(t, ports.MsgDetach, second.Type)
104- select {
105- case extra := <-out:
106- t.Fatalf("got extra frame after one input chunk: %v", extra.Type)
107- default:
108- }
109-}
110-
111-func TestStdinPumpWaitsForQueryAcknowledgementBeforeNextRead(t *testing.T) {
112- ctx, cancel := context.WithCancel(context.Background())
113- defer cancel()
114- allowSecond := make(chan struct{})
115- closeReader := make(chan struct{})
116- reader := &queryBoundaryReader{
117- first: []byte("\x1b]4;1;#112233\a\x1b[?997;2n"),
118- second: []byte("\x1b]4;2;#445566\a"),
119- readSecond: make(chan struct{}, 1),
120- allowSecond: allowSecond,
121- close: closeReader,
122- }
123- queryStarted := make(chan struct{})
124- allowQuery := make(chan struct{})
125- out := make(chan ports.Frame, 3)
126- pump := stdinPump{
127- ctx: ctx,
128- cancel: cancel,
129- in: reader,
130- out: out,
131- themeState: &terminalThemeState{},
132- logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
133- requestColors: func() (colorQueryRequest, bool) {
134- close(queryStarted)
135- select {
136- case <-allowQuery:
137- return colorQueryRequest{}, true
138- case <-ctx.Done():
139- return colorQueryRequest{}, false
140- }
141- },
142- }
143- pumpDone := make(chan struct{})
144- go func() {
145- pump.run()
146- close(pumpDone)
147- }()
148-
149- select {
150- case <-queryStarted:
151- case <-time.After(time.Second):
152- t.Fatal("palette re-query was not requested")
153- }
154- select {
155- case <-reader.readSecond:
156- t.Fatal("stdin pump started the next read before the query acknowledgement")
157- default:
158- }
159-
160- close(allowQuery)
161- select {
162- case <-reader.readSecond:
163- case <-time.After(time.Second):
164- t.Fatal("stdin pump did not resume after the query acknowledgement")
165- }
166-
167- cancel()
168- close(closeReader)
169- select {
170- case <-pumpDone:
171- case <-time.After(time.Second):
172- t.Fatal("stdin pump did not unwind after cancellation")
173- }
174-}
175-
176-type chunksReader struct {
177- chunks [][]byte
178- next int
179-}
180-
181-func (r *chunksReader) Read(p []byte) (int, error) {
182- if r.next == len(r.chunks) {
183- return 0, io.EOF
184- }
185- chunk := r.chunks[r.next]
186- r.next++
187- return copy(p, chunk), nil
188-}
189-
190-func TestStdinPumpDefersRepeatedSchemeChunksUntilBoundary(t *testing.T) {
191- ctx, cancel := context.WithCancel(context.Background())
192- defer cancel()
193- requests := 0
194- pump := stdinPump{
195- ctx: ctx,
196- cancel: cancel,
197- in: &chunksReader{chunks: [][]byte{
198- []byte("\x1b[?997;2n"),
199- []byte("\x1b[?997;1n"),
200- }},
201- out: make(chan ports.Frame, 3),
202- themeState: &terminalThemeState{},
203- logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
204- requestColors: func() (colorQueryRequest, bool) {
205- requests++
206- return colorQueryRequest{}, true
207- },
208- }
209-
210- pump.run()
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)
245-}
246-
247-func TestStdinPumpQueryAcknowledgementCancellationDoesNotDeadlock(t *testing.T) {
248- ctx, cancel := context.WithCancel(context.Background())
249- closeReader := make(chan struct{})
250- reader := &queryBoundaryReader{
251- first: []byte("\x1b[?997;2n"),
252- close: closeReader,
253- }
254- requestStarted := make(chan struct{})
255- pump := stdinPump{
256- ctx: ctx,
257- cancel: cancel,
258- in: reader,
259- out: make(chan ports.Frame, 2),
260- themeState: &terminalThemeState{},
261- logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
262- requestColors: func() (colorQueryRequest, bool) {
263- close(requestStarted)
264- <-ctx.Done()
265- return colorQueryRequest{}, false
266- },
267- }
268- pumpDone := make(chan struct{})
269- go func() {
270- pump.run()
271- close(pumpDone)
272- }()
273-
274- select {
275- case <-requestStarted:
276- case <-time.After(time.Second):
277- t.Fatal("palette re-query was not requested")
278- }
279- cancel()
280- select {
281- case <-pumpDone:
282- case <-time.After(time.Second):
283- t.Fatal("stdin pump deadlocked waiting for a cancelled query acknowledgement")
284- }
285- close(closeReader)
286-}
@@ -1,12 +1,7 @@
1 package client1 package client
2 2
3 import (3 import (
4- "bytes"
5- "context"
6- "io"
7- "log/slog"
8 "testing"4 "testing"
9- "time"
10 5
11 "github.com/stretchr/testify/require"6 "github.com/stretchr/testify/require"
12 7
@@ -41,246 +36,3 @@ func TestTerminalThemeStateClearPalettePreservesReportedColors(t *testing.T) {
41 _, reported := state.reportedTheme()36 _, reported := state.reportedTheme()
42 require.True(t, reported, "foreground/background remain usable after palette invalidation")37 require.True(t, reported, "foreground/background remain usable after palette invalidation")
43 }38 }
44-
45-type queryBoundaryReader struct {
46- first, second []byte
47- readSecond chan struct{}
48- allowSecond, close <-chan struct{}
49- reads int
50-}
51-
52-func (r *queryBoundaryReader) Read(p []byte) (int, error) {
53- switch r.reads {
54- case 0:
55- r.reads++
56- return copy(p, r.first), nil
57- case 1:
58- select {
59- case r.readSecond <- struct{}{}:
60- case <-r.close:
61- return 0, io.EOF
62- }
63- select {
64- case <-r.allowSecond:
65- r.reads++
66- return copy(p, r.second), nil
67- case <-r.close:
68- return 0, io.EOF
69- }
70- default:
71- <-r.close
72- return 0, io.EOF
73- }
74-}
75-
76-func TestStdinPumpCoalescesPaletteAndBackgroundThemePerRead(t *testing.T) {
77- state := &terminalThemeState{}
78- ctx, cancel := context.WithCancel(context.Background())
79- defer cancel()
80- out := make(chan ports.Frame, 3)
81- pump := stdinPump{
82- ctx: ctx,
83- cancel: cancel,
84- in: bytes.NewReader([]byte("\x1b]4;1;#112233\a\x1b]11;rgb:0404/0505/0606\a\x1b]4;14;#778899\a")),
85- out: out,
86- themeState: state,
87- logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
88- }
89-
90- pump.run()
91-
92- first := <-out
93- require.Equal(t, ports.MsgTheme, first.Type)
94- theme, err := ports.UnmarshalTheme(first.Payload)
95- require.NoError(t, err)
96- require.True(t, theme.HasBackground)
97- require.Equal(t, renderer.RGB{R: 4, G: 5, B: 6}, theme.Background)
98- require.Equal(t, uint16(1<<1|1<<14), theme.PaletteKnown)
99- require.Equal(t, renderer.RGB{R: 0x11, G: 0x22, B: 0x33}, theme.Palette[1])
100- require.Equal(t, renderer.RGB{R: 0x77, G: 0x88, B: 0x99}, theme.Palette[14])
101-
102- second := <-out
103- require.Equal(t, ports.MsgDetach, second.Type)
104- select {
105- case extra := <-out:
106- t.Fatalf("got extra frame after one input chunk: %v", extra.Type)
107- default:
108- }
109-}
110-
111-func TestStdinPumpWaitsForQueryAcknowledgementBeforeNextRead(t *testing.T) {
112- ctx, cancel := context.WithCancel(context.Background())
113- defer cancel()
114- allowSecond := make(chan struct{})
115- closeReader := make(chan struct{})
116- reader := &queryBoundaryReader{
117- first: []byte("\x1b]4;1;#112233\a\x1b[?997;2n"),
118- second: []byte("\x1b]4;2;#445566\a"),
119- readSecond: make(chan struct{}, 1),
120- allowSecond: allowSecond,
121- close: closeReader,
122- }
123- queryStarted := make(chan struct{})
124- allowQuery := make(chan struct{})
125- out := make(chan ports.Frame, 3)
126- pump := stdinPump{
127- ctx: ctx,
128- cancel: cancel,
129- in: reader,
130- out: out,
131- themeState: &terminalThemeState{},
132- logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
133- requestColors: func() (colorQueryRequest, bool) {
134- close(queryStarted)
135- select {
136- case <-allowQuery:
137- return colorQueryRequest{}, true
138- case <-ctx.Done():
139- return colorQueryRequest{}, false
140- }
141- },
142- }
143- pumpDone := make(chan struct{})
144- go func() {
145- pump.run()
146- close(pumpDone)
147- }()
148-
149- select {
150- case <-queryStarted:
151- case <-time.After(time.Second):
152- t.Fatal("palette re-query was not requested")
153- }
154- select {
155- case <-reader.readSecond:
156- t.Fatal("stdin pump started the next read before the query acknowledgement")
157- default:
158- }
159-
160- close(allowQuery)
161- select {
162- case <-reader.readSecond:
163- case <-time.After(time.Second):
164- t.Fatal("stdin pump did not resume after the query acknowledgement")
165- }
166-
167- cancel()
168- close(closeReader)
169- select {
170- case <-pumpDone:
171- case <-time.After(time.Second):
172- t.Fatal("stdin pump did not unwind after cancellation")
173- }
174-}
175-
176-type chunksReader struct {
177- chunks [][]byte
178- next int
179-}
180-
181-func (r *chunksReader) Read(p []byte) (int, error) {
182- if r.next == len(r.chunks) {
183- return 0, io.EOF
184- }
185- chunk := r.chunks[r.next]
186- r.next++
187- return copy(p, chunk), nil
188-}
189-
190-func TestStdinPumpDefersRepeatedSchemeChunksUntilBoundary(t *testing.T) {
191- ctx, cancel := context.WithCancel(context.Background())
192- defer cancel()
193- requests := 0
194- pump := stdinPump{
195- ctx: ctx,
196- cancel: cancel,
197- in: &chunksReader{chunks: [][]byte{
198- []byte("\x1b[?997;2n"),
199- []byte("\x1b[?997;1n"),
200- }},
201- out: make(chan ports.Frame, 3),
202- themeState: &terminalThemeState{},
203- logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
204- requestColors: func() (colorQueryRequest, bool) {
205- requests++
206- return colorQueryRequest{}, true
207- },
208- }
209-
210- pump.run()
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)
245-}
246-
247-func TestStdinPumpQueryAcknowledgementCancellationDoesNotDeadlock(t *testing.T) {
248- ctx, cancel := context.WithCancel(context.Background())
249- closeReader := make(chan struct{})
250- reader := &queryBoundaryReader{
251- first: []byte("\x1b[?997;2n"),
252- close: closeReader,
253- }
254- requestStarted := make(chan struct{})
255- pump := stdinPump{
256- ctx: ctx,
257- cancel: cancel,
258- in: reader,
259- out: make(chan ports.Frame, 2),
260- themeState: &terminalThemeState{},
261- logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
262- requestColors: func() (colorQueryRequest, bool) {
263- close(requestStarted)
264- <-ctx.Done()
265- return colorQueryRequest{}, false
266- },
267- }
268- pumpDone := make(chan struct{})
269- go func() {
270- pump.run()
271- close(pumpDone)
272- }()
273-
274- select {
275- case <-requestStarted:
276- case <-time.After(time.Second):
277- t.Fatal("palette re-query was not requested")
278- }
279- cancel()
280- select {
281- case <-pumpDone:
282- case <-time.After(time.Second):
283- t.Fatal("stdin pump deadlocked waiting for a cancelled query acknowledgement")
284- }
285- close(closeReader)
286-}
M internal/usecase/client/transport_observability_test.go +5 -3
@@ -37,7 +37,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
3737 defer in.unblock()
3838 term.EXPECT().In().Return(in).Maybe()
3939 term.EXPECT().Out().Return(&out).Maybe()
40- term.EXPECT().Flush().Run(func() { close(flushed) }).Return(nil).Once()
40+ term.EXPECT().Flush().Run(func() { closeOnce(flushed) }).Return(nil).Maybe()
4141 term.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
4242
4343 observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}
@@ -46,6 +46,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
4646 acked := make(chan struct{})
4747 transport := portsmocks.NewMockTransport(t)
4848 transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
49+ transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
4950 transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once()
5051 unblock := scriptRecv(transport,
5152 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
@@ -70,7 +71,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
7071 case <-time.After(time.Second):
7172 t.Fatal("Attach did not complete after observer release")
7273 }
73- require.Equal(t, "flush-before-observe", out.String())
74+ require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07\x1b[?2031$pflush-before-observe", out.String())
7475 require.Equal(t, int32(1), restores.Load())
7576 }
7677
@@ -86,6 +87,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
8687 defer reporter.Close()
8788 transport := portsmocks.NewMockTransport(t)
8889 transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
90+ transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
8991 unblock := scriptRecv(transport,
9092 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
9193 recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("unchanged-by-observer"), NewStateNum: 3}))},
@@ -98,7 +100,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
98100 err := runTestClient(context.Background(), testDependencies(transportDialer{transport: transport}, term, realClock{}, nil, reporter), client.AttachRequest{Intent: ports.IntentEphemeral})
99101 require.NoError(t, err)
100102 reporter.Flush()
101- require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes")
103+ require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07\x1b[?2031$punchanged-by-observer", out.String(), "observer must not alter terminal bytes")
102104 require.Equal(t, int32(1), restores.Load())
103105 // Carriage spans belong only to concrete adapters. The client owns the
104106 // post-successful-flush terminal boundary, before its ACK is queued.
@@ -37,7 +37,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
37 defer in.unblock()37 defer in.unblock()
38 term.EXPECT().In().Return(in).Maybe()38 term.EXPECT().In().Return(in).Maybe()
39 term.EXPECT().Out().Return(&out).Maybe()39 term.EXPECT().Out().Return(&out).Maybe()
40- term.EXPECT().Flush().Run(func() { close(flushed) }).Return(nil).Once()40+ term.EXPECT().Flush().Run(func() { closeOnce(flushed) }).Return(nil).Maybe()
41 term.EXPECT().ResizeEvents().Return(resizeCh).Maybe()41 term.EXPECT().ResizeEvents().Return(resizeCh).Maybe()
42 42
43 observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}43 observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})}
@@ -46,6 +46,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
46 acked := make(chan struct{})46 acked := make(chan struct{})
47 transport := portsmocks.NewMockTransport(t)47 transport := portsmocks.NewMockTransport(t)
48 transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()48 transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
49+ transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
49 transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once()50 transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once()
50 unblock := scriptRecv(transport,51 unblock := scriptRecv(transport,
51 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},52 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
@@ -70,7 +71,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) {
70 case <-time.After(time.Second):71 case <-time.After(time.Second):
71 t.Fatal("Attach did not complete after observer release")72 t.Fatal("Attach did not complete after observer release")
72 }73 }
73- require.Equal(t, "flush-before-observe", out.String())74+ require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07\x1b[?2031$pflush-before-observe", out.String())
74 require.Equal(t, int32(1), restores.Load())75 require.Equal(t, int32(1), restores.Load())
75 }76 }
76 77
@@ -86,6 +87,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
86 defer reporter.Close()87 defer reporter.Close()
87 transport := portsmocks.NewMockTransport(t)88 transport := portsmocks.NewMockTransport(t)
88 transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()89 transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once()
90+ transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe()
89 unblock := scriptRecv(transport,91 unblock := scriptRecv(transport,
90 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},92 recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))},
91 recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("unchanged-by-observer"), NewStateNum: 3}))},93 recvItem{f: frameOf(ports.MsgOutput, ports.MarshalOutput(ports.Output{Data: []byte("unchanged-by-observer"), NewStateNum: 3}))},
@@ -98,7 +100,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) {
98 err := runTestClient(context.Background(), testDependencies(transportDialer{transport: transport}, term, realClock{}, nil, reporter), client.AttachRequest{Intent: ports.IntentEphemeral})100 err := runTestClient(context.Background(), testDependencies(transportDialer{transport: transport}, term, realClock{}, nil, reporter), client.AttachRequest{Intent: ports.IntentEphemeral})
99 require.NoError(t, err)101 require.NoError(t, err)
100 reporter.Flush()102 reporter.Flush()
101- require.Equal(t, "unchanged-by-observer", out.String(), "observer must not alter terminal bytes")103+ require.Equal(t, "\x1b]10;?\x07\x1b]11;?\x07\x1b]4;0;?;1;?;2;?;3;?;4;?;5;?;6;?;7;?;8;?;9;?;10;?;11;?;12;?;13;?;14;?;15;?\x07\x1b[?2031$punchanged-by-observer", out.String(), "observer must not alter terminal bytes")
102 require.Equal(t, int32(1), restores.Load())104 require.Equal(t, int32(1), restores.Load())
103 // Carriage spans belong only to concrete adapters. The client owns the105 // Carriage spans belong only to concrete adapters. The client owns the
104 // post-successful-flush terminal boundary, before its ACK is queued.106 // post-successful-flush terminal boundary, before its ACK is queued.

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