feat(client): publish definitive palette generations
- commit
- 12b1b9bd74b5a1e0398e88344c40c4e34753204b
- author
- brice · · 2026-07-18 06:30 UTC
- parent
- 588bb23
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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) { | |||
| 89 | 89 | ||
| 90 | 90 | select { | |
| 91 | 91 | 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 | |
| 93 | 93 | if string(got) != want { | |
| 94 | 94 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 95 | 95 | } | |
| @@ -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 + altScreenExit | 92 | + | 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 ( | |||
| 31 | 31 | bracketedPasteDisable = "\x1b[?2004l" | |
| 32 | 32 | colorSchemeEnable = "\x1b[?2031h" | |
| 33 | 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 | 36 | // bufSize is the batched writer's buffer capacity. | |
| @@ -110,7 +107,7 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 110 | 107 | } | |
| 111 | 108 | 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 | 111 | _ = t.restoreRawLocked() | |
| 115 | 112 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | |
| 116 | 113 | } | |
| @@ -237,20 +234,6 @@ func (t *Terminal) stopResizeLocked() { | |||
| 237 | 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 | 237 | // In returns the reader for the controlling terminal's input. | |
| 255 | 238 | func (t *Terminal) In() io.Reader { | |
| 256 | 239 | 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 = old | 108 | 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.in | 239 | return t.in | ||
M internal/adapters/term/terminal_test.go +7 -29
| @@ -10,8 +10,6 @@ import ( | |||
| 10 | 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 | 13 | // pipePair returns an os.Pipe and a goroutine that continuously copies | |
| 16 | 14 | // everything written to the write end into captured, closing done once | |
| 17 | 15 | // the write end is closed (EOF). | |
| @@ -62,10 +60,13 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||
| 62 | 60 | _ = outW.Close() | |
| 63 | 61 | <-done | |
| 64 | 62 | ||
| 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 | |
| 66 | 64 | if got := captured.String(); got != want { | |
| 67 | 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 | 72 | func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |
| @@ -100,37 +101,14 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||
| 100 | 101 | _ = outW.Close() | |
| 101 | 102 | <-done | |
| 102 | 103 | ||
| 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 | |
| 107 | 107 | if got := captured.String(); got != want { | |
| 108 | 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 | 112 | func TestTerminal_InOut(t *testing.T) { | |
| 135 | 113 | inR, inW, err := os.Pipe() | |
| 136 | 114 | 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 copies | 13 | // pipePair returns an os.Pipe and a goroutine that continuously copies | ||
| 16 | // everything written to the write end into captured, closing done once | 14 | // 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 | <-done | 61 | <-done | ||
| 64 | 62 | ||||
| 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 |
| 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 | <-done | 102 | <-done | ||
| 102 | 103 | ||||
| 103 | - | // Alt-screen/cursor/theme-query escapes must appear exactly once for enter and | 104 | + | // Alt-screen/cursor escapes must appear exactly once for enter and exits |
| 104 | - | // cursor/alt-screen exits exactly once, regardless of how many times | 105 | + | // 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 | |||
| 229 | 229 | 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 | 232 | // ResizeEvents provides a mock function for the type MockTerminal | |
| 277 | 233 | func (_mock *MockTerminal) ResizeEvents() <-chan domain.Size { | |
| 278 | 234 | ret := _mock.Called() | |
| @@ -229,50 +229,6 @@ func (_c *MockTerminal_Out_Call) RunAndReturn(run func() io.Writer) *MockTermina | |||||
| 229 | return _c | 229 | 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 MockTerminal | 232 | // 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 { | |||
| 11 | 11 | EnterRaw() (restore func() error, err error) | |
| 12 | 12 | Size() (domain.Size, error) | |
| 13 | 13 | ResizeEvents() <-chan domain.Size | |
| 14 | - | QueryColors() error | |
| 15 | 14 | In() io.Reader | |
| 16 | 15 | Out() io.Writer | |
| 17 | 16 | 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.Size | 13 | ResizeEvents() <-chan domain.Size | ||
| 14 | - | QueryColors() error | |||
| 15 | In() io.Reader | 14 | In() io.Reader | ||
| 16 | Out() io.Writer | 15 | Out() io.Writer | ||
| 17 | Flush() error | 16 | Flush() error | ||
M internal/usecase/client/client.go +156 -243
| @@ -586,50 +586,121 @@ func (a *attachAttempt) run(ctx context.Context) attachResult { | |||
| 586 | 586 | return welcomedResult(err) | |
| 587 | 587 | } | |
| 588 | 588 | reconnect.clear() | |
| 589 | - | reportedTheme, restoreTheme := themeState.reportedTheme() | |
| 590 | 589 | ||
| 591 | 590 | // 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. | |
| 594 | 593 | loopCtx, cancel := context.WithCancel(ctx) | |
| 595 | 594 | defer cancel() | |
| 596 | 595 | ||
| 597 | 596 | sendCh := make(chan ports.Frame, sendQueueDepth) | |
| 598 | - | if restoreTheme { | |
| 599 | - | sendCh <- ports.Frame{Type: ports.MsgTheme, Payload: ports.MarshalTheme(reportedTheme)} | |
| 600 | - | } | |
| 601 | 597 | ackQueue := newCumulativeAckQueue() | |
| 602 | 598 | 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 | |
| 618 | 623 | } | |
| 619 | 624 | select { | |
| 620 | - | case <-request.boundaryWritten: | |
| 621 | - | return request, true | |
| 625 | + | case sendCh <- frame: | |
| 626 | + | return true | |
| 622 | 627 | case <-loopCtx.Done(): | |
| 623 | - | return colorQueryRequest{}, false | |
| 628 | + | 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 | 697 | var clip ports.ClipboardReader | |
| 628 | 698 | if remote { | |
| 629 | 699 | clip = clipboard | |
| 630 | 700 | } | |
| 701 | + | senderStarted = true | |
| 631 | 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 | 704 | go runResize(loopCtx, term.ResizeEvents(), sendCh, log) | |
| 634 | 705 | ||
| 635 | 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 | 739 | // token. Run closes this transport on the way out. | |
| 669 | 740 | return welcomedResult(errLinkOffline) | |
| 670 | 741 | } | |
| 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 | |
| 689 | 754 | } | |
| 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) | |
| 692 | 757 | } | |
| 693 | - | close(ready.queryIssued) | |
| 694 | 758 | case r := <-recvCh: | |
| 695 | 759 | if r.err != nil { | |
| 696 | 760 | if errors.Is(r.err, io.EOF) { | |
| @@ -848,148 +912,57 @@ func runSender(ctx context.Context, cancel context.CancelFunc, transport ports.T | |||
| 848 | 912 | // exits. That is harmless here — Run has already returned and restored the | |
| 849 | 913 | // terminal — and matches the standard pattern for stdin pumps; a | |
| 850 | 914 | // 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. | |
| 934 | 918 | 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 | |
| 945 | 928 | } | |
| 946 | 929 | ||
| 947 | 930 | 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") | |
| 958 | 932 | buf := make([]byte, stdinBufSize) | |
| 959 | 933 | var scanner theme.Scanner | |
| 934 | + | var markers paletteMarkerScanner | |
| 960 | 935 | var inputSeq uint64 | |
| 961 | 936 | var sendOK atomic.Bool | |
| 962 | 937 | sendOK.Store(true) | |
| 963 | 938 | send := func(frame ports.Frame) { | |
| 964 | 939 | 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(): | |
| 967 | 949 | sendOK.Store(false) | |
| 968 | 950 | } | |
| 969 | 951 | } | |
| 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) { | |
| 974 | 953 | if len(data) == 0 { | |
| 975 | 954 | return | |
| 976 | 955 | } | |
| 977 | - | copyData := append([]byte(nil), data...) | |
| 978 | 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 | 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 | 960 | sink := coalescer.Scan | |
| 988 | - | if clipboard != nil { | |
| 961 | + | if p.clipboard != nil { | |
| 989 | 962 | ci := &clipboardIntercept{ | |
| 990 | 963 | coalescer: coalescer, | |
| 991 | - | reader: clipboard, | |
| 992 | - | log: log, | |
| 964 | + | reader: p.clipboard, | |
| 965 | + | log: p.logger, | |
| 993 | 966 | sendImage: func(mime string, data []byte) { | |
| 994 | 967 | inputSeq++ | |
| 995 | 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 | 972 | sink = ci.Scan | |
| 1000 | 973 | } | |
| 1001 | - | ||
| 1002 | - | phase := paletteAccepting | |
| 1003 | - | var drain paletteDrain | |
| 974 | + | id := func() paletteGenerationID { | |
| 975 | + | return paletteGenerationID(p.activeGeneration.Load()) | |
| 976 | + | } | |
| 1004 | 977 | for { | |
| 1005 | - | n, rerr := in.Read(buf) | |
| 978 | + | n, rerr := p.in.Read(buf) | |
| 1006 | 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 | 980 | 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 | |
| 1032 | 984 | } | |
| 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}) | |
| 1044 | 986 | }, 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}) | |
| 1053 | 988 | }, 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}) | |
| 1057 | 993 | }) | |
| 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 | + | }) | |
| 1079 | 995 | if !sendOK.Load() { | |
| 1080 | 996 | return | |
| 1081 | 997 | } | |
| 1082 | 998 | } | |
| 1083 | 999 | if rerr != nil { | |
| 1084 | - | if phase == paletteDraining { | |
| 1085 | - | drain.flush(sink) | |
| 1086 | - | } | |
| 1087 | - | // Best-effort detach notification, then unwind. | |
| 1000 | + | markers.flush(sink) | |
| 1088 | 1001 | 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(): | |
| 1091 | 1004 | } | |
| 1092 | - | cancel() | |
| 1005 | + | p.cancel() | |
| 1093 | 1006 | return | |
| 1094 | 1007 | } | |
| 1095 | 1008 | } | |
| @@ -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 the | 590 | // 4. Derive a cancellable context so the pumps always unwind when the | ||
| 592 | - | // loop returns. cancel runs first (LIFO): it signals the pumps; the | 591 | + | // 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. The | 600 | + | paletteEvents := make(chan paletteGenerationEvent, 32) |
| 605 | - | // stdin pump drains terminal replies through its mode-specific response | 601 | + | var activeGeneration atomic.Uint64 |
| 606 | - | // before it permits the main loop to publish the cleared scheme and issue | 602 | + | coordinator := newPaletteGenerationCoordinator() |
| 607 | - | // QueryColors. This separates bytes already queued (including | 603 | + | type paletteTimer struct { |
| 608 | - | // Scanner.pending) from replacement replies without allowing a second | 604 | + | 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{}, false | 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 | |||
| 618 | } | 623 | } | ||
| 619 | select { | 624 | select { | ||
| 620 | - | case <-request.boundaryWritten: | 625 | + | case sendCh <- frame: |
| 621 | - | return request, true | 626 | + | return true |
| 622 | case <-loopCtx.Done(): | 627 | case <-loopCtx.Done(): | ||
| 623 | - | return colorQueryRequest{}, false | 628 | + | 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.ClipboardReader | 697 | var clip ports.ClipboardReader | ||
| 628 | if remote { | 698 | if remote { | ||
| 629 | clip = clipboard | 699 | 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 been | 751 | + | return welcomedResult(err) |
| 681 | - | // discarded by stdin. Publish the one cleared scheme snapshot before | 752 | + | } |
| 682 | - | // emitting the query; stdin remains paused until QueryColors has | 753 | + | 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 the | 912 | // exits. That is harmless here — Run has already returned and restored the | ||
| 849 | // terminal — and matches the standard pattern for stdin pumps; a | 913 | // 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.Context | 919 | + | ctx context.Context |
| 936 | - | cancel context.CancelFunc | 920 | + | cancel context.CancelFunc |
| 937 | - | in io.Reader | 921 | + | in io.Reader |
| 938 | - | out chan<- ports.Frame | 922 | + | out chan<- ports.Frame |
| 939 | - | clock ports.Clock | 923 | + | clock ports.Clock |
| 940 | - | themeState *terminalThemeState | 924 | + | clipboard ports.ClipboardReader |
| 941 | - | clipboard ports.ClipboardReader | 925 | + | logger *slog.Logger |
| 942 | - | logger *slog.Logger | 926 | + | 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.ctx | 931 | + | 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.Scanner | 933 | var scanner theme.Scanner | ||
| 934 | + | var markers paletteMarkerScanner | |||
| 960 | var inputSeq uint64 | 935 | var inputSeq uint64 | ||
| 961 | var sendOK atomic.Bool | 936 | 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 one | 952 | + | 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 | return | 954 | 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.Scan | 960 | 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.Scan | 972 | sink = ci.Scan | ||
| 1000 | } | 973 | } | ||
| 1001 | - | 974 | + | id := func() paletteGenerationID { | |
| 1002 | - | phase := paletteAccepting | 975 | + | return paletteGenerationID(p.activeGeneration.Load()) |
| 1003 | - | var drain paletteDrain | 976 | + | } |
| 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 | - | return | 982 | + | 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 = true | 990 | + | }, func(data []byte) { |
| 1056 | - | current.Light = light | 991 | + | markers.scan(data, sink, func() { |
| 992 | + | sendEvent(paletteGenerationEvent{id: id(), kind: paletteEventMarker}) | |||
| 1057 | }) | 993 | }) | ||
| 1058 | - | // The scheme itself begins a new palette generation. Suppress every | 994 | + | }) |
| 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 | return | 996 | 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 | return | 1006 | return | ||
| 1094 | } | 1007 | } | ||
| 1095 | } | 1008 | } | ||
M internal/usecase/client/client_test.go +27 -365
| @@ -230,6 +230,8 @@ func TestRunBoundsPreWelcomeOperations(t *testing.T) { | |||
| 230 | 230 | closed := make(chan struct{}) | |
| 231 | 231 | operationExited := make(chan struct{}) | |
| 232 | 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 | 235 | tr.EXPECT().Close().Run(func() { close(closed) }).Return(nil).Once() | |
| 234 | 236 | ||
| 235 | 237 | block := func() error { | |
| @@ -308,6 +310,8 @@ func TestAttachHelloIncludesTrueColor(t *testing.T) { | |||
| 308 | 310 | ||
| 309 | 311 | gotHello := make(chan ports.Hello, 1) | |
| 310 | 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 | 315 | tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error { | |
| 312 | 316 | hello, err := ports.UnmarshalHello(f.Payload) | |
| 313 | 317 | require.NoError(t, err) | |
| @@ -345,6 +349,8 @@ func TestAttachHelloIncludesCompleteLocalEnvironment(t *testing.T) { | |||
| 345 | 349 | ||
| 346 | 350 | gotHello := make(chan ports.Hello, 1) | |
| 347 | 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 | 354 | tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error { | |
| 349 | 355 | hello, err := ports.UnmarshalHello(f.Payload) | |
| 350 | 356 | if err != nil { | |
| @@ -372,6 +378,8 @@ func TestAttachHelloRequestsSingleOutputForDatagramTransport(t *testing.T) { | |||
| 372 | 378 | defer in.unblock() | |
| 373 | 379 | ||
| 374 | 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 | 383 | tr.EXPECT().Send(isType(ports.MsgHello)).RunAndReturn(func(f ports.Frame) error { | |
| 376 | 384 | hello, err := ports.UnmarshalHello(f.Payload) | |
| 377 | 385 | require.NoError(t, err) | |
| @@ -396,6 +404,8 @@ func TestAttachHappyPath(t *testing.T) { | |||
| 396 | 404 | defer in.unblock() | |
| 397 | 405 | ||
| 398 | 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 | 409 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 400 | 410 | unblock := scriptRecv(tr, | |
| 401 | 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 | 418 | err := runTestClient(context.Background(), attachTestDependencies(tr, tm, realClock{}), client.AttachRequest{Intent: ports.IntentEphemeral, SessionName: ""}) | |
| 409 | 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 | 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 | 427 | // EnterRaw must NOT be called on the error-before-welcome path. | |
| 418 | 428 | ||
| 419 | 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 | 432 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 421 | 433 | tr.EXPECT().Recv().Return( | |
| 422 | 434 | frameOf(ports.MsgError, ports.MarshalErrorMsg(ports.ErrorMsg{Code: ports.ErrVersionMismatch, Text: "version mismatch"})), | |
| @@ -439,6 +451,8 @@ func TestAttachRestoredOnRecvErrorMidStream(t *testing.T) { | |||
| 439 | 451 | defer in.unblock() | |
| 440 | 452 | ||
| 441 | 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 | 456 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 443 | 457 | boom := errors.New("connection reset") | |
| 444 | 458 | unblock := scriptRecv(tr, | |
| @@ -461,6 +475,8 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) { | |||
| 461 | 475 | defer in.unblock() | |
| 462 | 476 | ||
| 463 | 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 | 480 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 465 | 481 | unblock := scriptRecv(tr, | |
| 466 | 482 | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))}, | |
| @@ -475,221 +491,6 @@ func TestAttachDaemonVanishedOnEOF(t *testing.T) { | |||
| 475 | 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 | 494 | func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) { | |
| 694 | 495 | var out bytes.Buffer | |
| 695 | 496 | var restoreCount atomic.Int32 | |
| @@ -708,6 +509,8 @@ func TestAttachStdinForwardsSGRMouseReportAsSingleFrame(t *testing.T) { | |||
| 708 | 509 | gotInput := make(chan []byte, 2) | |
| 709 | 510 | allowDetach := make(chan struct{}) | |
| 710 | 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 | 514 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 712 | 515 | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 713 | 516 | in, err := ports.UnmarshalInput(f.Payload) | |
| @@ -768,6 +571,8 @@ func TestAttachStdinCoalescesSplitBracketedPaste(t *testing.T) { | |||
| 768 | 571 | gotInput := make(chan []byte, 1) | |
| 769 | 572 | allowDetach := make(chan struct{}) | |
| 770 | 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 | 576 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 772 | 577 | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 773 | 578 | in, err := ports.UnmarshalInput(f.Payload) | |
| @@ -821,6 +626,8 @@ func TestAttachForwardsResize(t *testing.T) { | |||
| 821 | 626 | var firstRecvOnce sync.Once | |
| 822 | 627 | ||
| 823 | 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 | 631 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 825 | 632 | // The resize frame is forwarded via the sender goroutine. | |
| 826 | 633 | gotResize := make(chan ports.Resize, 1) | |
| @@ -880,6 +687,8 @@ func TestRunRestoresRawModeAfterAttachError(t *testing.T) { | |||
| 880 | 687 | defer in.unblock() | |
| 881 | 688 | ||
| 882 | 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 | 692 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 884 | 693 | boom := errors.New("connection reset") | |
| 885 | 694 | unblock := scriptRecv(tr, | |
| @@ -902,6 +711,8 @@ func TestRunDoesNotEnterRawBeforePreWelcomeError(t *testing.T) { | |||
| 902 | 711 | // EnterRaw must NOT be called when the daemon rejects Hello before Welcome. | |
| 903 | 712 | ||
| 904 | 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 | 716 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 906 | 717 | tr.EXPECT().Recv().Return( | |
| 907 | 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 | 799 | func (t *runTerminal) Size() (domain.Size, error) { return domain.Size{Cols: 80, Rows: 24}, nil } | |
| 989 | 800 | func (t *runTerminal) ResizeEvents() <-chan domain.Size { return t.resizeCh } | |
| 990 | - | func (t *runTerminal) QueryColors() error { return nil } | |
| 991 | 801 | func (t *runTerminal) In() io.Reader { return t.in } | |
| 992 | 802 | func (t *runTerminal) Out() io.Writer { return &t.out } | |
| 993 | 803 | func (t *runTerminal) Flush() error { return nil } | |
| @@ -1047,151 +857,3 @@ func TestRunDoesNotRetryTerminalDetachedError(t *testing.T) { | |||
| 1047 | 857 | require.Equal(t, int32(1), d.calls.Load()) | |
| 1048 | 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 | - | } | |
| @@ -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.Buffer | 495 | var out bytes.Buffer | ||
| 695 | var restoreCount atomic.Int32 | 496 | 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.Once | 626 | 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 | |||
| 42 | 42 | allowDetach := make(chan struct{}) | |
| 43 | 43 | ||
| 44 | 44 | tr := portsmocks.NewMockTransport(t) | |
| 45 | + | tr.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 45 | 46 | tr.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 46 | 47 | tr.EXPECT().Send(isType(ports.MsgInput)).RunAndReturn(func(f ports.Frame) error { | |
| 47 | 48 | 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 ( | |||
| 46 | 46 | paletteEventForeground | |
| 47 | 47 | paletteEventBackground | |
| 48 | 48 | paletteEventPalette | |
| 49 | + | paletteEventScheme | |
| 49 | 50 | ) | |
| 50 | 51 | ||
| 51 | 52 | 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 | |
| 56 | 58 | } | |
| 57 | 59 | ||
| 58 | 60 | // paletteGenerationAction is deliberately declarative. The attach loop is | |
| @@ -214,6 +216,7 @@ func (c *paletteGenerationCoordinator) finalize() []paletteGenerationAction { | |||
| 214 | 216 | c.active = false | |
| 215 | 217 | c.finalized = c.current.theme | |
| 216 | 218 | return []paletteGenerationAction{ | |
| 219 | + | {kind: paletteActionCancelDrainDeadline, id: c.current.id}, | |
| 217 | 220 | {kind: paletteActionCancelCompletionDeadline, id: c.current.id}, | |
| 218 | 221 | {kind: paletteActionPublishFinal, id: c.current.id, theme: c.finalized}, | |
| 219 | 222 | } | |
| @@ -250,37 +253,41 @@ var paletteMarkerResponses = [][]byte{ | |||
| 250 | 253 | } | |
| 251 | 254 | ||
| 252 | 255 | func (s *paletteMarkerScanner) scan(data []byte, onBytes func([]byte), onMarker func()) { | |
| 253 | - | for _, b := range data { | |
| 256 | + | for i, b := range data { | |
| 254 | 257 | 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 | |
| 269 | 262 | } | |
| 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 | |
| 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 | 278 | onBytes(s.pending[:1]) | |
| 279 | 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 | 291 | func (s *paletteMarkerScanner) flush(onBytes func([]byte)) { | |
| 285 | 292 | if len(s.pending) == 0 { | |
| 286 | 293 | return | |
| @@ -46,13 +46,15 @@ const ( | |||||
| 46 | paletteEventForeground | 46 | paletteEventForeground | ||
| 47 | paletteEventBackground | 47 | paletteEventBackground | ||
| 48 | paletteEventPalette | 48 | paletteEventPalette | ||
| 49 | + | paletteEventScheme | |||
| 49 | ) | 50 | ) | ||
| 50 | 51 | ||||
| 51 | type paletteGenerationEvent struct { | 52 | type paletteGenerationEvent struct { | ||
| 52 | - | id paletteGenerationID | 53 | + | id paletteGenerationID |
| 53 | - | kind paletteGenerationEventKind | 54 | + | kind paletteGenerationEventKind |
| 54 | - | rgb renderer.RGB | 55 | + | rgb renderer.RGB |
| 55 | - | slot uint8 | 56 | + | slot uint8 |
| 57 | + | light bool | |||
| 56 | } | 58 | } | ||
| 57 | 59 | ||||
| 58 | // paletteGenerationAction is deliberately declarative. The attach loop is | 60 | // paletteGenerationAction is deliberately declarative. The attach loop is | ||
| @@ -214,6 +216,7 @@ func (c *paletteGenerationCoordinator) finalize() []paletteGenerationAction { | |||||
| 214 | c.active = false | 216 | c.active = false | ||
| 215 | c.finalized = c.current.theme | 217 | 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, false | 259 | + | 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 = nil | 264 | + | matched = true |
| 272 | - | onMarker() | 265 | + | } else { |
| 273 | - | break | 266 | + | 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 | return | 293 | return | ||
M internal/usecase/client/palette_generation_test.go +1 -0
| @@ -114,6 +114,7 @@ func TestPaletteGenerationInitialPublishesClearedThenDefinitive(t *testing.T) { | |||
| 114 | 114 | clock.apply(final) | |
| 115 | 115 | ||
| 116 | 116 | require.Equal(t, []paletteGenerationActionKind{ | |
| 117 | + | paletteActionCancelDrainDeadline, | |
| 117 | 118 | paletteActionCancelCompletionDeadline, | |
| 118 | 119 | paletteActionPublishFinal, | |
| 119 | 120 | }, 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 | |||
| 43 | 43 | h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe() | |
| 44 | 44 | h.term.EXPECT().Size().Return(h.size, nil).Maybe() | |
| 45 | 45 | h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe() | |
| 46 | - | h.term.EXPECT().QueryColors().Return(nil).Maybe() | |
| 47 | 46 | h.term.EXPECT().In().Return(h.in).Maybe() | |
| 48 | 47 | h.term.EXPECT().Out().Return(&h.out).Maybe() | |
| 49 | 48 | h.term.EXPECT().Flush().Return(nil).Maybe() | |
| @@ -63,7 +62,6 @@ func newReconnectToastTerminalHarnessWithOutput(t *testing.T, out io.Writer) *re | |||
| 63 | 62 | h.term.EXPECT().EnterRaw().Return(func() error { return nil }, nil).Maybe() | |
| 64 | 63 | h.term.EXPECT().Size().Return(h.size, nil).Maybe() | |
| 65 | 64 | h.term.EXPECT().ResizeEvents().Return((<-chan domain.Size)(h.resizeCh)).Maybe() | |
| 66 | - | h.term.EXPECT().QueryColors().Return(nil).Maybe() | |
| 67 | 65 | h.term.EXPECT().In().Return(h.in).Maybe() | |
| 68 | 66 | h.term.EXPECT().Out().Return(out).Maybe() | |
| 69 | 67 | h.term.EXPECT().Flush().Return(nil).Maybe() | |
| @@ -115,6 +113,7 @@ func reconnectToastDetach(reason uint8) ports.Frame { | |||
| 115 | 113 | func mockReconnectTransport(t *testing.T, recvs ...reconnectToastRecv) *portsmocks.MockTransport { | |
| 116 | 114 | t.Helper() | |
| 117 | 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 | 117 | tr.EXPECT().Send(mock.Anything).Return(nil).Maybe() | |
| 119 | 118 | for _, recv := range recvs { | |
| 120 | 119 | tr.EXPECT().Recv().Return(recv.frame, recv.err).Once() | |
| @@ -217,6 +216,8 @@ func newReconnectHandshakeClock(t *testing.T) *portsmocks.MockClock { | |||
| 217 | 216 | timer.EXPECT().C().Return((<-chan time.Time)(make(chan time.Time))).Maybe() | |
| 218 | 217 | timer.EXPECT().Stop().Return(true).Maybe() | |
| 219 | 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 | 221 | return clk | |
| 221 | 222 | } | |
| 222 | 223 | ||
| @@ -594,76 +595,6 @@ func (t *blockingThemeRestoreTransport) Close() error { | |||
| 594 | 595 | 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 | 598 | func TestRemoteEphemeralReconnectUsesAssignedSessionName(t *testing.T) { | |
| 668 | 599 | linkDead := errors.New("remote link dead") | |
| 669 | 600 | 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 clk | 221 | return clk | ||
| 221 | } | 222 | } | ||
| 222 | 223 | ||||
| @@ -594,76 +595,6 @@ func (t *blockingThemeRestoreTransport) Close() error { | |||||
| 594 | return nil | 595 | 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 := reconnectSleep | 600 | oldSleep := reconnectSleep | ||
M internal/usecase/client/theme_state_test.go +0 -248
| @@ -1,12 +1,7 @@ | |||
| 1 | 1 | package client | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "context" | |
| 6 | - | "io" | |
| 7 | - | "log/slog" | |
| 8 | 4 | "testing" | |
| 9 | - | "time" | |
| 10 | 5 | ||
| 11 | 6 | "github.com/stretchr/testify/require" | |
| 12 | 7 | ||
| @@ -41,246 +36,3 @@ func TestTerminalThemeStateClearPalettePreservesReportedColors(t *testing.T) { | |||
| 41 | 36 | _, reported := state.reportedTheme() | |
| 42 | 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 | - | } | |
| @@ -1,12 +1,7 @@ | |||||
| 1 | package client | 1 | 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) { | |||
| 37 | 37 | defer in.unblock() | |
| 38 | 38 | term.EXPECT().In().Return(in).Maybe() | |
| 39 | 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 | 41 | term.EXPECT().ResizeEvents().Return(resizeCh).Maybe() | |
| 42 | 42 | ||
| 43 | 43 | observer := &blockingClientRuntimeObserver{entered: make(chan struct{}), release: make(chan struct{})} | |
| @@ -46,6 +46,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) { | |||
| 46 | 46 | acked := make(chan struct{}) | |
| 47 | 47 | transport := portsmocks.NewMockTransport(t) | |
| 48 | 48 | transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 49 | + | transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 49 | 50 | transport.EXPECT().Send(isType(ports.MsgAck)).Run(func(ports.Frame) { close(acked) }).Return(nil).Once() | |
| 50 | 51 | unblock := scriptRecv(transport, | |
| 51 | 52 | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))}, | |
| @@ -70,7 +71,7 @@ func TestBlockingRuntimeObserverDoesNotDelayTerminalFlushOrACK(t *testing.T) { | |||
| 70 | 71 | case <-time.After(time.Second): | |
| 71 | 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 | 75 | require.Equal(t, int32(1), restores.Load()) | |
| 75 | 76 | } | |
| 76 | 77 | ||
| @@ -86,6 +87,7 @@ func TestTerminalFlushBoundaryTransportObservability(t *testing.T) { | |||
| 86 | 87 | defer reporter.Close() | |
| 87 | 88 | transport := portsmocks.NewMockTransport(t) | |
| 88 | 89 | transport.EXPECT().Send(isType(ports.MsgHello)).Return(nil).Once() | |
| 90 | + | transport.EXPECT().Send(isType(ports.MsgTheme)).Return(nil).Maybe() | |
| 89 | 91 | unblock := scriptRecv(transport, | |
| 90 | 92 | recvItem{f: frameOf(ports.MsgWelcome, ports.MarshalWelcome(ports.Welcome{SessionID: "s1"}))}, | |
| 91 | 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 | 100 | err := runTestClient(context.Background(), testDependencies(transportDialer{transport: transport}, term, realClock{}, nil, reporter), client.AttachRequest{Intent: ports.IntentEphemeral}) | |
| 99 | 101 | require.NoError(t, err) | |
| 100 | 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 | 104 | require.Equal(t, int32(1), restores.Load()) | |
| 103 | 105 | // Carriage spans belong only to concrete adapters. The client owns the | |
| 104 | 106 | // 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 the | 105 | // 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. | ||