feat(term): client terminal adapter — raw mode, alt-screen, SIGWINCH coalescing, batched writer
Implements ports.Terminal for the client-side controlling terminal: idempotent EnterRaw/restore (x/term MakeRaw/Restore, alt-screen 1049h/l, cursor show/hide), Size via x/term.GetSize, ResizeEvents with a lazily-started SIGWINCH watcher that drain-and-coalesces bursts to the latest size, and a bufio-backed 64KiB batched writer for Out/Flush. Raw-mode syscalls are skipped (and recorded) when the backing fd isn't a tty, so EnterRaw stays testable over pipes; one integration test exercises the real-tty paths against a self-contained /dev/ptmx PTY pair. Adds golang.org/x/term and golang.org/x/sys (test-only) deps.
- commit
- 786a71f2f545252012c8c4f1ee1ea3418e921a22
- author
- brice · · 2026-07-02 11:07 UTC
- committer
- brice · · 2026-07-02 11:16 UTC
- parent
- 6b2d35b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
9 files · +893 -0
M go.mod +1 -0
| @@ -5,6 +5,7 @@ go 1.26 | |||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/stretchr/testify v1.11.1 | |
| 7 | 7 | golang.org/x/sys v0.46.0 | |
| 8 | + | golang.org/x/term v0.44.0 | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | require ( | |
| @@ -5,6 +5,7 @@ go 1.26 | |||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/stretchr/testify v1.11.1 | 6 | github.com/stretchr/testify v1.11.1 | ||
| 7 | golang.org/x/sys v0.46.0 | 7 | golang.org/x/sys v0.46.0 | ||
| 8 | + | golang.org/x/term v0.44.0 | |||
| 8 | ) | 9 | ) | ||
| 9 | 10 | ||||
| 10 | require ( | 11 | require ( | ||
M go.sum +2 -0
| @@ -8,6 +8,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu | |||
| 8 | 8 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | |
| 9 | 9 | golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= | |
| 10 | 10 | golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | |
| 11 | + | golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= | |
| 12 | + | golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= | |
| 11 | 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= | |
| 12 | 14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | |
| 13 | 15 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | |
| @@ -8,6 +8,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu | |||||
| 8 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | 8 | github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= | ||
| 9 | golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= | 9 | golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= | ||
| 10 | golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | 10 | golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= | ||
| 11 | + | golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= | |||
| 12 | + | golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= | |||
| 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= | 13 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= | ||
| 12 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | 14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= | ||
| 13 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | 15 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= | ||
A internal/adapters/term/batchwriter.go +51 -0
| @@ -0,0 +1,51 @@ | |||
| 1 | + | package term | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bufio" | |
| 5 | + | "io" | |
| 6 | + | "sync" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // batchWriter is a thread-safe buffered writer. Writes accumulate in an | |
| 10 | + | // internal buffer until Flush is called; a single write larger than the | |
| 11 | + | // buffer's capacity passes straight through to the underlying sink | |
| 12 | + | // (bufio.Writer's standard behavior). | |
| 13 | + | type batchWriter struct { | |
| 14 | + | mu sync.Mutex | |
| 15 | + | bw *bufio.Writer | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | // newBatchWriter wraps out with a buffer of the given size. | |
| 19 | + | func newBatchWriter(out io.Writer, size int) *batchWriter { | |
| 20 | + | return &batchWriter{bw: bufio.NewWriterSize(out, size)} | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // Write buffers p, flushing to the underlying sink only as bufio.Writer | |
| 24 | + | // requires (e.g. when p would overflow the buffer). | |
| 25 | + | func (w *batchWriter) Write(p []byte) (int, error) { | |
| 26 | + | w.mu.Lock() | |
| 27 | + | defer w.mu.Unlock() | |
| 28 | + | return w.bw.Write(p) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // WriteString is the string analogue of Write. | |
| 32 | + | func (w *batchWriter) WriteString(s string) (int, error) { | |
| 33 | + | w.mu.Lock() | |
| 34 | + | defer w.mu.Unlock() | |
| 35 | + | return w.bw.WriteString(s) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // Flush writes any buffered data to the underlying sink. | |
| 39 | + | func (w *batchWriter) Flush() error { | |
| 40 | + | w.mu.Lock() | |
| 41 | + | defer w.mu.Unlock() | |
| 42 | + | return w.bw.Flush() | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | // Buffered reports the number of bytes currently buffered and not yet | |
| 46 | + | // flushed to the underlying sink. | |
| 47 | + | func (w *batchWriter) Buffered() int { | |
| 48 | + | w.mu.Lock() | |
| 49 | + | defer w.mu.Unlock() | |
| 50 | + | return w.bw.Buffered() | |
| 51 | + | } | |
| @@ -0,0 +1,51 @@ | |||||
| 1 | + | package term | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bufio" | |||
| 5 | + | "io" | |||
| 6 | + | "sync" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // batchWriter is a thread-safe buffered writer. Writes accumulate in an | |||
| 10 | + | // internal buffer until Flush is called; a single write larger than the | |||
| 11 | + | // buffer's capacity passes straight through to the underlying sink | |||
| 12 | + | // (bufio.Writer's standard behavior). | |||
| 13 | + | type batchWriter struct { | |||
| 14 | + | mu sync.Mutex | |||
| 15 | + | bw *bufio.Writer | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | // newBatchWriter wraps out with a buffer of the given size. | |||
| 19 | + | func newBatchWriter(out io.Writer, size int) *batchWriter { | |||
| 20 | + | return &batchWriter{bw: bufio.NewWriterSize(out, size)} | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | // Write buffers p, flushing to the underlying sink only as bufio.Writer | |||
| 24 | + | // requires (e.g. when p would overflow the buffer). | |||
| 25 | + | func (w *batchWriter) Write(p []byte) (int, error) { | |||
| 26 | + | w.mu.Lock() | |||
| 27 | + | defer w.mu.Unlock() | |||
| 28 | + | return w.bw.Write(p) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | // WriteString is the string analogue of Write. | |||
| 32 | + | func (w *batchWriter) WriteString(s string) (int, error) { | |||
| 33 | + | w.mu.Lock() | |||
| 34 | + | defer w.mu.Unlock() | |||
| 35 | + | return w.bw.WriteString(s) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // Flush writes any buffered data to the underlying sink. | |||
| 39 | + | func (w *batchWriter) Flush() error { | |||
| 40 | + | w.mu.Lock() | |||
| 41 | + | defer w.mu.Unlock() | |||
| 42 | + | return w.bw.Flush() | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | // Buffered reports the number of bytes currently buffered and not yet | |||
| 46 | + | // flushed to the underlying sink. | |||
| 47 | + | func (w *batchWriter) Buffered() int { | |||
| 48 | + | w.mu.Lock() | |||
| 49 | + | defer w.mu.Unlock() | |||
| 50 | + | return w.bw.Buffered() | |||
| 51 | + | } | |||
A internal/adapters/term/batchwriter_test.go +130 -0
| @@ -0,0 +1,130 @@ | |||
| 1 | + | package term | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // recordingWriter records each Write call it receives, copying the bytes | |
| 9 | + | // so later mutations by the caller (e.g. buffer reuse) don't corrupt the | |
| 10 | + | // recorded history. | |
| 11 | + | type recordingWriter struct { | |
| 12 | + | writes [][]byte | |
| 13 | + | failNext bool | |
| 14 | + | } | |
| 15 | + | ||
| 16 | + | func (r *recordingWriter) Write(p []byte) (int, error) { | |
| 17 | + | if r.failNext { | |
| 18 | + | r.failNext = false | |
| 19 | + | return 0, errors.New("boom") | |
| 20 | + | } | |
| 21 | + | cp := make([]byte, len(p)) | |
| 22 | + | copy(cp, p) | |
| 23 | + | r.writes = append(r.writes, cp) | |
| 24 | + | return len(p), nil | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | func (r *recordingWriter) all() []byte { | |
| 28 | + | var out []byte | |
| 29 | + | for _, w := range r.writes { | |
| 30 | + | out = append(out, w...) | |
| 31 | + | } | |
| 32 | + | return out | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestBatchWriter_BufferedUntilFlush(t *testing.T) { | |
| 36 | + | rw := &recordingWriter{} | |
| 37 | + | bw := newBatchWriter(rw, 64) | |
| 38 | + | ||
| 39 | + | n, err := bw.Write([]byte("hello")) | |
| 40 | + | if err != nil || n != 5 { | |
| 41 | + | t.Fatalf("Write() = %d, %v; want 5, nil", n, err) | |
| 42 | + | } | |
| 43 | + | if len(rw.writes) != 0 { | |
| 44 | + | t.Fatalf("expected no writes reaching sink before Flush, got %d", len(rw.writes)) | |
| 45 | + | } | |
| 46 | + | if got := bw.Buffered(); got != 5 { | |
| 47 | + | t.Fatalf("Buffered() = %d, want 5", got) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | if err := bw.Flush(); err != nil { | |
| 51 | + | t.Fatalf("Flush() = %v, want nil", err) | |
| 52 | + | } | |
| 53 | + | if got := string(rw.all()); got != "hello" { | |
| 54 | + | t.Fatalf("sink got %q, want %q", got, "hello") | |
| 55 | + | } | |
| 56 | + | if got := bw.Buffered(); got != 0 { | |
| 57 | + | t.Fatalf("Buffered() after Flush = %d, want 0", got) | |
| 58 | + | } | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | func TestBatchWriter_MultipleWritesCoalesceUntilFlush(t *testing.T) { | |
| 62 | + | rw := &recordingWriter{} | |
| 63 | + | bw := newBatchWriter(rw, 64) | |
| 64 | + | ||
| 65 | + | for _, s := range []string{"a", "b", "c"} { | |
| 66 | + | if _, err := bw.WriteString(s); err != nil { | |
| 67 | + | t.Fatalf("WriteString(%q): %v", s, err) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if len(rw.writes) != 0 { | |
| 71 | + | t.Fatalf("expected 0 writes to sink before Flush, got %d", len(rw.writes)) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | if err := bw.Flush(); err != nil { | |
| 75 | + | t.Fatalf("Flush(): %v", err) | |
| 76 | + | } | |
| 77 | + | if got := string(rw.all()); got != "abc" { | |
| 78 | + | t.Fatalf("sink got %q, want %q", got, "abc") | |
| 79 | + | } | |
| 80 | + | // bufio.Writer.Flush issues a single underlying Write. | |
| 81 | + | if len(rw.writes) != 1 { | |
| 82 | + | t.Fatalf("expected exactly 1 write to sink on Flush, got %d", len(rw.writes)) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestBatchWriter_LargeWritePassesThrough(t *testing.T) { | |
| 87 | + | rw := &recordingWriter{} | |
| 88 | + | bw := newBatchWriter(rw, 8) // small buffer | |
| 89 | + | ||
| 90 | + | big := make([]byte, 64) | |
| 91 | + | for i := range big { | |
| 92 | + | big[i] = 'x' | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | n, err := bw.Write(big) | |
| 96 | + | if err != nil || n != len(big) { | |
| 97 | + | t.Fatalf("Write(big) = %d, %v; want %d, nil", n, err, len(big)) | |
| 98 | + | } | |
| 99 | + | // A write larger than the buffer bypasses buffering entirely. | |
| 100 | + | if len(rw.writes) == 0 { | |
| 101 | + | t.Fatalf("expected large write to pass through immediately, got 0 sink writes") | |
| 102 | + | } | |
| 103 | + | if got := string(rw.all()); got != string(big) { | |
| 104 | + | t.Fatalf("sink content mismatch: got %d bytes, want %d bytes", len(got), len(big)) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestBatchWriter_FlushPropagatesError(t *testing.T) { | |
| 109 | + | rw := &recordingWriter{failNext: true} | |
| 110 | + | bw := newBatchWriter(rw, 64) | |
| 111 | + | ||
| 112 | + | if _, err := bw.WriteString("x"); err != nil { | |
| 113 | + | t.Fatalf("WriteString: %v", err) | |
| 114 | + | } | |
| 115 | + | if err := bw.Flush(); err == nil { | |
| 116 | + | t.Fatalf("expected Flush error, got nil") | |
| 117 | + | } | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | func TestBatchWriter_FlushOnEmptyBufferIsNoop(t *testing.T) { | |
| 121 | + | rw := &recordingWriter{} | |
| 122 | + | bw := newBatchWriter(rw, 64) | |
| 123 | + | ||
| 124 | + | if err := bw.Flush(); err != nil { | |
| 125 | + | t.Fatalf("Flush() on empty buffer: %v", err) | |
| 126 | + | } | |
| 127 | + | if len(rw.writes) != 0 { | |
| 128 | + | t.Fatalf("expected no sink writes, got %d", len(rw.writes)) | |
| 129 | + | } | |
| 130 | + | } | |
| @@ -0,0 +1,130 @@ | |||||
| 1 | + | package term | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | // recordingWriter records each Write call it receives, copying the bytes | |||
| 9 | + | // so later mutations by the caller (e.g. buffer reuse) don't corrupt the | |||
| 10 | + | // recorded history. | |||
| 11 | + | type recordingWriter struct { | |||
| 12 | + | writes [][]byte | |||
| 13 | + | failNext bool | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func (r *recordingWriter) Write(p []byte) (int, error) { | |||
| 17 | + | if r.failNext { | |||
| 18 | + | r.failNext = false | |||
| 19 | + | return 0, errors.New("boom") | |||
| 20 | + | } | |||
| 21 | + | cp := make([]byte, len(p)) | |||
| 22 | + | copy(cp, p) | |||
| 23 | + | r.writes = append(r.writes, cp) | |||
| 24 | + | return len(p), nil | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func (r *recordingWriter) all() []byte { | |||
| 28 | + | var out []byte | |||
| 29 | + | for _, w := range r.writes { | |||
| 30 | + | out = append(out, w...) | |||
| 31 | + | } | |||
| 32 | + | return out | |||
| 33 | + | } | |||
| 34 | + | ||||
| 35 | + | func TestBatchWriter_BufferedUntilFlush(t *testing.T) { | |||
| 36 | + | rw := &recordingWriter{} | |||
| 37 | + | bw := newBatchWriter(rw, 64) | |||
| 38 | + | ||||
| 39 | + | n, err := bw.Write([]byte("hello")) | |||
| 40 | + | if err != nil || n != 5 { | |||
| 41 | + | t.Fatalf("Write() = %d, %v; want 5, nil", n, err) | |||
| 42 | + | } | |||
| 43 | + | if len(rw.writes) != 0 { | |||
| 44 | + | t.Fatalf("expected no writes reaching sink before Flush, got %d", len(rw.writes)) | |||
| 45 | + | } | |||
| 46 | + | if got := bw.Buffered(); got != 5 { | |||
| 47 | + | t.Fatalf("Buffered() = %d, want 5", got) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | if err := bw.Flush(); err != nil { | |||
| 51 | + | t.Fatalf("Flush() = %v, want nil", err) | |||
| 52 | + | } | |||
| 53 | + | if got := string(rw.all()); got != "hello" { | |||
| 54 | + | t.Fatalf("sink got %q, want %q", got, "hello") | |||
| 55 | + | } | |||
| 56 | + | if got := bw.Buffered(); got != 0 { | |||
| 57 | + | t.Fatalf("Buffered() after Flush = %d, want 0", got) | |||
| 58 | + | } | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | func TestBatchWriter_MultipleWritesCoalesceUntilFlush(t *testing.T) { | |||
| 62 | + | rw := &recordingWriter{} | |||
| 63 | + | bw := newBatchWriter(rw, 64) | |||
| 64 | + | ||||
| 65 | + | for _, s := range []string{"a", "b", "c"} { | |||
| 66 | + | if _, err := bw.WriteString(s); err != nil { | |||
| 67 | + | t.Fatalf("WriteString(%q): %v", s, err) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | if len(rw.writes) != 0 { | |||
| 71 | + | t.Fatalf("expected 0 writes to sink before Flush, got %d", len(rw.writes)) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | if err := bw.Flush(); err != nil { | |||
| 75 | + | t.Fatalf("Flush(): %v", err) | |||
| 76 | + | } | |||
| 77 | + | if got := string(rw.all()); got != "abc" { | |||
| 78 | + | t.Fatalf("sink got %q, want %q", got, "abc") | |||
| 79 | + | } | |||
| 80 | + | // bufio.Writer.Flush issues a single underlying Write. | |||
| 81 | + | if len(rw.writes) != 1 { | |||
| 82 | + | t.Fatalf("expected exactly 1 write to sink on Flush, got %d", len(rw.writes)) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestBatchWriter_LargeWritePassesThrough(t *testing.T) { | |||
| 87 | + | rw := &recordingWriter{} | |||
| 88 | + | bw := newBatchWriter(rw, 8) // small buffer | |||
| 89 | + | ||||
| 90 | + | big := make([]byte, 64) | |||
| 91 | + | for i := range big { | |||
| 92 | + | big[i] = 'x' | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | n, err := bw.Write(big) | |||
| 96 | + | if err != nil || n != len(big) { | |||
| 97 | + | t.Fatalf("Write(big) = %d, %v; want %d, nil", n, err, len(big)) | |||
| 98 | + | } | |||
| 99 | + | // A write larger than the buffer bypasses buffering entirely. | |||
| 100 | + | if len(rw.writes) == 0 { | |||
| 101 | + | t.Fatalf("expected large write to pass through immediately, got 0 sink writes") | |||
| 102 | + | } | |||
| 103 | + | if got := string(rw.all()); got != string(big) { | |||
| 104 | + | t.Fatalf("sink content mismatch: got %d bytes, want %d bytes", len(got), len(big)) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestBatchWriter_FlushPropagatesError(t *testing.T) { | |||
| 109 | + | rw := &recordingWriter{failNext: true} | |||
| 110 | + | bw := newBatchWriter(rw, 64) | |||
| 111 | + | ||||
| 112 | + | if _, err := bw.WriteString("x"); err != nil { | |||
| 113 | + | t.Fatalf("WriteString: %v", err) | |||
| 114 | + | } | |||
| 115 | + | if err := bw.Flush(); err == nil { | |||
| 116 | + | t.Fatalf("expected Flush error, got nil") | |||
| 117 | + | } | |||
| 118 | + | } | |||
| 119 | + | ||||
| 120 | + | func TestBatchWriter_FlushOnEmptyBufferIsNoop(t *testing.T) { | |||
| 121 | + | rw := &recordingWriter{} | |||
| 122 | + | bw := newBatchWriter(rw, 64) | |||
| 123 | + | ||||
| 124 | + | if err := bw.Flush(); err != nil { | |||
| 125 | + | t.Fatalf("Flush() on empty buffer: %v", err) | |||
| 126 | + | } | |||
| 127 | + | if len(rw.writes) != 0 { | |||
| 128 | + | t.Fatalf("expected no sink writes, got %d", len(rw.writes)) | |||
| 129 | + | } | |||
| 130 | + | } | |||
A internal/adapters/term/pty_integration_test.go +112 -0
| @@ -0,0 +1,112 @@ | |||
| 1 | + | //go:build linux | |
| 2 | + | ||
| 3 | + | package term | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "io" | |
| 8 | + | "os" | |
| 9 | + | "testing" | |
| 10 | + | "time" | |
| 11 | + | ||
| 12 | + | "golang.org/x/sys/unix" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // openPTYPair opens a real Linux PTY master/slave pair directly via | |
| 16 | + | // /dev/ptmx, self-contained (no import of the vev pty adapter package, | |
| 17 | + | // which is being built in parallel). | |
| 18 | + | func openPTYPair(t *testing.T) (master, slave *os.File) { | |
| 19 | + | t.Helper() | |
| 20 | + | ||
| 21 | + | m, err := os.OpenFile("/dev/ptmx", os.O_RDWR|unix.O_NOCTTY, 0) | |
| 22 | + | if err != nil { | |
| 23 | + | t.Fatalf("open /dev/ptmx: %v", err) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | if err := unix.IoctlSetPointerInt(int(m.Fd()), unix.TIOCSPTLCK, 0); err != nil { | |
| 27 | + | m.Close() | |
| 28 | + | t.Fatalf("unlock pty (TIOCSPTLCK): %v", err) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | n, err := unix.IoctlGetInt(int(m.Fd()), unix.TIOCGPTN) | |
| 32 | + | if err != nil { | |
| 33 | + | m.Close() | |
| 34 | + | t.Fatalf("get pty number (TIOCGPTN): %v", err) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | slavePath := fmt.Sprintf("/dev/pts/%d", n) | |
| 38 | + | s, err := os.OpenFile(slavePath, os.O_RDWR|unix.O_NOCTTY, 0) | |
| 39 | + | if err != nil { | |
| 40 | + | m.Close() | |
| 41 | + | t.Fatalf("open slave %s: %v", slavePath, err) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | return m, s | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // TestTerminal_RealPTY_RawModeSizeAndEscapes is the ONE integration test | |
| 48 | + | // exercising the real-tty paths (MakeRaw/Restore, GetSize) against an | |
| 49 | + | // actual PTY, since those are pure-syscall lines that unit tests with | |
| 50 | + | // pipes can't cover. | |
| 51 | + | func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |
| 52 | + | master, slave := openPTYPair(t) | |
| 53 | + | defer master.Close() | |
| 54 | + | defer slave.Close() | |
| 55 | + | ||
| 56 | + | ws := &unix.Winsize{Row: 24, Col: 80} | |
| 57 | + | if err := unix.IoctlSetWinsize(int(slave.Fd()), unix.TIOCSWINSZ, ws); err != nil { | |
| 58 | + | t.Fatalf("set winsize: %v", err) | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // Drain everything written to the slave (escape sequences) so the | |
| 62 | + | // batched writer's Flush never blocks on the PTY's internal buffer. | |
| 63 | + | captured := make(chan []byte, 1) | |
| 64 | + | go func() { | |
| 65 | + | buf, _ := io.ReadAll(master) | |
| 66 | + | captured <- buf | |
| 67 | + | }() | |
| 68 | + | ||
| 69 | + | tm := NewWithFiles(slave, slave) | |
| 70 | + | ||
| 71 | + | sz, err := tm.Size() | |
| 72 | + | if err != nil { | |
| 73 | + | t.Fatalf("Size: %v", err) | |
| 74 | + | } | |
| 75 | + | if sz.Cols != 80 || sz.Rows != 24 { | |
| 76 | + | t.Fatalf("Size = %+v, want {Cols:80 Rows:24}", sz) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | restore, err := tm.EnterRaw() | |
| 80 | + | if err != nil { | |
| 81 | + | t.Fatalf("EnterRaw: %v", err) | |
| 82 | + | } | |
| 83 | + | if tm.rawSkipped { | |
| 84 | + | t.Fatalf("expected rawSkipped=false for a real PTY") | |
| 85 | + | } | |
| 86 | + | ||
| 87 | + | if err := restore(); err != nil { | |
| 88 | + | t.Fatalf("restore: %v", err) | |
| 89 | + | } | |
| 90 | + | // Idempotent. | |
| 91 | + | if err := restore(); err != nil { | |
| 92 | + | t.Fatalf("second restore: %v", err) | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | // Closing the slave lets the reader goroutine observe EOF on the | |
| 96 | + | // master naturally. Don't also close master here: that would race | |
| 97 | + | // the reader's in-flight Read against this goroutine's Close on the | |
| 98 | + | // same fd and could discard already-buffered-but-unread data. | |
| 99 | + | if err := slave.Close(); err != nil { | |
| 100 | + | t.Fatalf("close slave: %v", err) | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | select { | |
| 104 | + | case got := <-captured: | |
| 105 | + | want := altScreenEnter + cursorHide + cursorShow + altScreenExit | |
| 106 | + | if string(got) != want { | |
| 107 | + | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 108 | + | } | |
| 109 | + | case <-time.After(5 * time.Second): | |
| 110 | + | t.Fatalf("timed out waiting for captured PTY output") | |
| 111 | + | } | |
| 112 | + | } | |
| @@ -0,0 +1,112 @@ | |||||
| 1 | + | //go:build linux | |||
| 2 | + | ||||
| 3 | + | package term | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "io" | |||
| 8 | + | "os" | |||
| 9 | + | "testing" | |||
| 10 | + | "time" | |||
| 11 | + | ||||
| 12 | + | "golang.org/x/sys/unix" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // openPTYPair opens a real Linux PTY master/slave pair directly via | |||
| 16 | + | // /dev/ptmx, self-contained (no import of the vev pty adapter package, | |||
| 17 | + | // which is being built in parallel). | |||
| 18 | + | func openPTYPair(t *testing.T) (master, slave *os.File) { | |||
| 19 | + | t.Helper() | |||
| 20 | + | ||||
| 21 | + | m, err := os.OpenFile("/dev/ptmx", os.O_RDWR|unix.O_NOCTTY, 0) | |||
| 22 | + | if err != nil { | |||
| 23 | + | t.Fatalf("open /dev/ptmx: %v", err) | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | if err := unix.IoctlSetPointerInt(int(m.Fd()), unix.TIOCSPTLCK, 0); err != nil { | |||
| 27 | + | m.Close() | |||
| 28 | + | t.Fatalf("unlock pty (TIOCSPTLCK): %v", err) | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | n, err := unix.IoctlGetInt(int(m.Fd()), unix.TIOCGPTN) | |||
| 32 | + | if err != nil { | |||
| 33 | + | m.Close() | |||
| 34 | + | t.Fatalf("get pty number (TIOCGPTN): %v", err) | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | slavePath := fmt.Sprintf("/dev/pts/%d", n) | |||
| 38 | + | s, err := os.OpenFile(slavePath, os.O_RDWR|unix.O_NOCTTY, 0) | |||
| 39 | + | if err != nil { | |||
| 40 | + | m.Close() | |||
| 41 | + | t.Fatalf("open slave %s: %v", slavePath, err) | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | return m, s | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | // TestTerminal_RealPTY_RawModeSizeAndEscapes is the ONE integration test | |||
| 48 | + | // exercising the real-tty paths (MakeRaw/Restore, GetSize) against an | |||
| 49 | + | // actual PTY, since those are pure-syscall lines that unit tests with | |||
| 50 | + | // pipes can't cover. | |||
| 51 | + | func TestTerminal_RealPTY_RawModeSizeAndEscapes(t *testing.T) { | |||
| 52 | + | master, slave := openPTYPair(t) | |||
| 53 | + | defer master.Close() | |||
| 54 | + | defer slave.Close() | |||
| 55 | + | ||||
| 56 | + | ws := &unix.Winsize{Row: 24, Col: 80} | |||
| 57 | + | if err := unix.IoctlSetWinsize(int(slave.Fd()), unix.TIOCSWINSZ, ws); err != nil { | |||
| 58 | + | t.Fatalf("set winsize: %v", err) | |||
| 59 | + | } | |||
| 60 | + | ||||
| 61 | + | // Drain everything written to the slave (escape sequences) so the | |||
| 62 | + | // batched writer's Flush never blocks on the PTY's internal buffer. | |||
| 63 | + | captured := make(chan []byte, 1) | |||
| 64 | + | go func() { | |||
| 65 | + | buf, _ := io.ReadAll(master) | |||
| 66 | + | captured <- buf | |||
| 67 | + | }() | |||
| 68 | + | ||||
| 69 | + | tm := NewWithFiles(slave, slave) | |||
| 70 | + | ||||
| 71 | + | sz, err := tm.Size() | |||
| 72 | + | if err != nil { | |||
| 73 | + | t.Fatalf("Size: %v", err) | |||
| 74 | + | } | |||
| 75 | + | if sz.Cols != 80 || sz.Rows != 24 { | |||
| 76 | + | t.Fatalf("Size = %+v, want {Cols:80 Rows:24}", sz) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | restore, err := tm.EnterRaw() | |||
| 80 | + | if err != nil { | |||
| 81 | + | t.Fatalf("EnterRaw: %v", err) | |||
| 82 | + | } | |||
| 83 | + | if tm.rawSkipped { | |||
| 84 | + | t.Fatalf("expected rawSkipped=false for a real PTY") | |||
| 85 | + | } | |||
| 86 | + | ||||
| 87 | + | if err := restore(); err != nil { | |||
| 88 | + | t.Fatalf("restore: %v", err) | |||
| 89 | + | } | |||
| 90 | + | // Idempotent. | |||
| 91 | + | if err := restore(); err != nil { | |||
| 92 | + | t.Fatalf("second restore: %v", err) | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | // Closing the slave lets the reader goroutine observe EOF on the | |||
| 96 | + | // master naturally. Don't also close master here: that would race | |||
| 97 | + | // the reader's in-flight Read against this goroutine's Close on the | |||
| 98 | + | // same fd and could discard already-buffered-but-unread data. | |||
| 99 | + | if err := slave.Close(); err != nil { | |||
| 100 | + | t.Fatalf("close slave: %v", err) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | select { | |||
| 104 | + | case got := <-captured: | |||
| 105 | + | want := altScreenEnter + cursorHide + cursorShow + altScreenExit | |||
| 106 | + | if string(got) != want { | |||
| 107 | + | t.Fatalf("captured escapes = %q, want %q", got, want) | |||
| 108 | + | } | |||
| 109 | + | case <-time.After(5 * time.Second): | |||
| 110 | + | t.Fatalf("timed out waiting for captured PTY output") | |||
| 111 | + | } | |||
| 112 | + | } | |||
A internal/adapters/term/resize.go +50 -0
| @@ -0,0 +1,50 @@ | |||
| 1 | + | package term | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/vev/internal/domain" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // resizeLoop watches sig for signal notifications (SIGWINCH in | |
| 10 | + | // production) and emits the coalesced terminal size on out: a burst of | |
| 11 | + | // signals collapses to a single emitted size, since any signals still | |
| 12 | + | // queued once the first is observed are drained before querying the | |
| 13 | + | // size. getSize is called to resolve the size to emit; a getSize error | |
| 14 | + | // is treated as "no event this round" and the loop continues waiting. | |
| 15 | + | // | |
| 16 | + | // The loop exits and closes out when quit is closed. | |
| 17 | + | func resizeLoop(sig <-chan os.Signal, out chan<- domain.Size, quit <-chan struct{}, getSize func() (domain.Size, error)) { | |
| 18 | + | defer close(out) | |
| 19 | + | for { | |
| 20 | + | select { | |
| 21 | + | case <-quit: | |
| 22 | + | return | |
| 23 | + | case <-sig: | |
| 24 | + | drainSignals(sig) | |
| 25 | + | sz, err := getSize() | |
| 26 | + | if err != nil { | |
| 27 | + | continue | |
| 28 | + | } | |
| 29 | + | select { | |
| 30 | + | case out <- sz: | |
| 31 | + | case <-quit: | |
| 32 | + | return | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // drainSignals removes any currently queued signals from sig without | |
| 39 | + | // blocking. Called right after receiving one signal, it collapses a | |
| 40 | + | // burst that arrived (or queued up) concurrently into that single | |
| 41 | + | // wakeup. | |
| 42 | + | func drainSignals(sig <-chan os.Signal) { | |
| 43 | + | for { | |
| 44 | + | select { | |
| 45 | + | case <-sig: | |
| 46 | + | default: | |
| 47 | + | return | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | } | |
| @@ -0,0 +1,50 @@ | |||||
| 1 | + | package term | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/vev/internal/domain" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // resizeLoop watches sig for signal notifications (SIGWINCH in | |||
| 10 | + | // production) and emits the coalesced terminal size on out: a burst of | |||
| 11 | + | // signals collapses to a single emitted size, since any signals still | |||
| 12 | + | // queued once the first is observed are drained before querying the | |||
| 13 | + | // size. getSize is called to resolve the size to emit; a getSize error | |||
| 14 | + | // is treated as "no event this round" and the loop continues waiting. | |||
| 15 | + | // | |||
| 16 | + | // The loop exits and closes out when quit is closed. | |||
| 17 | + | func resizeLoop(sig <-chan os.Signal, out chan<- domain.Size, quit <-chan struct{}, getSize func() (domain.Size, error)) { | |||
| 18 | + | defer close(out) | |||
| 19 | + | for { | |||
| 20 | + | select { | |||
| 21 | + | case <-quit: | |||
| 22 | + | return | |||
| 23 | + | case <-sig: | |||
| 24 | + | drainSignals(sig) | |||
| 25 | + | sz, err := getSize() | |||
| 26 | + | if err != nil { | |||
| 27 | + | continue | |||
| 28 | + | } | |||
| 29 | + | select { | |||
| 30 | + | case out <- sz: | |||
| 31 | + | case <-quit: | |||
| 32 | + | return | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // drainSignals removes any currently queued signals from sig without | |||
| 39 | + | // blocking. Called right after receiving one signal, it collapses a | |||
| 40 | + | // burst that arrived (or queued up) concurrently into that single | |||
| 41 | + | // wakeup. | |||
| 42 | + | func drainSignals(sig <-chan os.Signal) { | |||
| 43 | + | for { | |||
| 44 | + | select { | |||
| 45 | + | case <-sig: | |||
| 46 | + | default: | |||
| 47 | + | return | |||
| 48 | + | } | |||
| 49 | + | } | |||
| 50 | + | } | |||
A internal/adapters/term/resize_test.go +125 -0
| @@ -0,0 +1,125 @@ | |||
| 1 | + | package term | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "os" | |
| 6 | + | "syscall" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ||
| 10 | + | "github.com/bnema/vev/internal/domain" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestResizeLoop_CoalescesBurstToOneEmission(t *testing.T) { | |
| 14 | + | // Fill the signal channel with a burst before the loop starts | |
| 15 | + | // consuming: draining is synchronous and deterministic here since | |
| 16 | + | // nothing else sends concurrently. | |
| 17 | + | sig := make(chan os.Signal, 8) | |
| 18 | + | for i := 0; i < 5; i++ { | |
| 19 | + | sig <- syscall.SIGWINCH | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | out := make(chan domain.Size) | |
| 23 | + | quit := make(chan struct{}) | |
| 24 | + | ||
| 25 | + | var calls int | |
| 26 | + | getSize := func() (domain.Size, error) { | |
| 27 | + | calls++ | |
| 28 | + | return domain.Size{Cols: 80, Rows: 24}, nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | go resizeLoop(sig, out, quit, getSize) | |
| 32 | + | ||
| 33 | + | got := <-out | |
| 34 | + | want := domain.Size{Cols: 80, Rows: 24} | |
| 35 | + | if got != want { | |
| 36 | + | t.Fatalf("emitted size = %+v, want %+v", got, want) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | select { | |
| 40 | + | case sz2, ok := <-out: | |
| 41 | + | t.Fatalf("unexpected second emission (ok=%v): %+v", ok, sz2) | |
| 42 | + | case <-time.After(50 * time.Millisecond): | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | if calls != 1 { | |
| 46 | + | t.Fatalf("getSize called %d times, want 1 (burst should coalesce)", calls) | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | close(quit) | |
| 50 | + | if _, ok := <-out; ok { | |
| 51 | + | t.Fatalf("expected out to be closed after quit") | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) { | |
| 56 | + | sig := make(chan os.Signal) // unbuffered: sends rendezvous with the loop's receive | |
| 57 | + | out := make(chan domain.Size) | |
| 58 | + | quit := make(chan struct{}) | |
| 59 | + | ||
| 60 | + | var calls int | |
| 61 | + | getSize := func() (domain.Size, error) { | |
| 62 | + | calls++ | |
| 63 | + | if calls == 1 { | |
| 64 | + | return domain.Size{}, errors.New("boom") | |
| 65 | + | } | |
| 66 | + | return domain.Size{Cols: 10, Rows: 5}, nil | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | go resizeLoop(sig, out, quit, getSize) | |
| 70 | + | ||
| 71 | + | sig <- syscall.SIGWINCH // errors; no emission | |
| 72 | + | sig <- syscall.SIGWINCH // succeeds | |
| 73 | + | ||
| 74 | + | got := <-out | |
| 75 | + | want := domain.Size{Cols: 10, Rows: 5} | |
| 76 | + | if got != want { | |
| 77 | + | t.Fatalf("emitted size = %+v, want %+v", got, want) | |
| 78 | + | } | |
| 79 | + | if calls != 2 { | |
| 80 | + | t.Fatalf("getSize called %d times, want 2", calls) | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | close(quit) | |
| 84 | + | if _, ok := <-out; ok { | |
| 85 | + | t.Fatalf("expected out to be closed after quit") | |
| 86 | + | } | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | func TestResizeLoop_QuitClosesOutImmediately(t *testing.T) { | |
| 90 | + | sig := make(chan os.Signal) | |
| 91 | + | out := make(chan domain.Size) | |
| 92 | + | quit := make(chan struct{}) | |
| 93 | + | ||
| 94 | + | getSize := func() (domain.Size, error) { | |
| 95 | + | t.Fatalf("getSize should not be called") | |
| 96 | + | return domain.Size{}, nil | |
| 97 | + | } | |
| 98 | + | ||
| 99 | + | go resizeLoop(sig, out, quit, getSize) | |
| 100 | + | close(quit) | |
| 101 | + | ||
| 102 | + | select { | |
| 103 | + | case _, ok := <-out: | |
| 104 | + | if ok { | |
| 105 | + | t.Fatalf("expected out to be closed, got a value") | |
| 106 | + | } | |
| 107 | + | case <-time.After(time.Second): | |
| 108 | + | t.Fatalf("out was not closed after quit") | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func TestDrainSignals_RemovesQueuedSignalsOnly(t *testing.T) { | |
| 113 | + | sig := make(chan os.Signal, 4) | |
| 114 | + | sig <- syscall.SIGWINCH | |
| 115 | + | sig <- syscall.SIGWINCH | |
| 116 | + | sig <- syscall.SIGWINCH | |
| 117 | + | ||
| 118 | + | drainSignals(sig) | |
| 119 | + | ||
| 120 | + | select { | |
| 121 | + | case s := <-sig: | |
| 122 | + | t.Fatalf("expected sig to be empty after drain, got %v", s) | |
| 123 | + | default: | |
| 124 | + | } | |
| 125 | + | } | |
| @@ -0,0 +1,125 @@ | |||||
| 1 | + | package term | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "os" | |||
| 6 | + | "syscall" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/vev/internal/domain" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestResizeLoop_CoalescesBurstToOneEmission(t *testing.T) { | |||
| 14 | + | // Fill the signal channel with a burst before the loop starts | |||
| 15 | + | // consuming: draining is synchronous and deterministic here since | |||
| 16 | + | // nothing else sends concurrently. | |||
| 17 | + | sig := make(chan os.Signal, 8) | |||
| 18 | + | for i := 0; i < 5; i++ { | |||
| 19 | + | sig <- syscall.SIGWINCH | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | out := make(chan domain.Size) | |||
| 23 | + | quit := make(chan struct{}) | |||
| 24 | + | ||||
| 25 | + | var calls int | |||
| 26 | + | getSize := func() (domain.Size, error) { | |||
| 27 | + | calls++ | |||
| 28 | + | return domain.Size{Cols: 80, Rows: 24}, nil | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | go resizeLoop(sig, out, quit, getSize) | |||
| 32 | + | ||||
| 33 | + | got := <-out | |||
| 34 | + | want := domain.Size{Cols: 80, Rows: 24} | |||
| 35 | + | if got != want { | |||
| 36 | + | t.Fatalf("emitted size = %+v, want %+v", got, want) | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | select { | |||
| 40 | + | case sz2, ok := <-out: | |||
| 41 | + | t.Fatalf("unexpected second emission (ok=%v): %+v", ok, sz2) | |||
| 42 | + | case <-time.After(50 * time.Millisecond): | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | if calls != 1 { | |||
| 46 | + | t.Fatalf("getSize called %d times, want 1 (burst should coalesce)", calls) | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | close(quit) | |||
| 50 | + | if _, ok := <-out; ok { | |||
| 51 | + | t.Fatalf("expected out to be closed after quit") | |||
| 52 | + | } | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestResizeLoop_GetSizeErrorSkipsEmission(t *testing.T) { | |||
| 56 | + | sig := make(chan os.Signal) // unbuffered: sends rendezvous with the loop's receive | |||
| 57 | + | out := make(chan domain.Size) | |||
| 58 | + | quit := make(chan struct{}) | |||
| 59 | + | ||||
| 60 | + | var calls int | |||
| 61 | + | getSize := func() (domain.Size, error) { | |||
| 62 | + | calls++ | |||
| 63 | + | if calls == 1 { | |||
| 64 | + | return domain.Size{}, errors.New("boom") | |||
| 65 | + | } | |||
| 66 | + | return domain.Size{Cols: 10, Rows: 5}, nil | |||
| 67 | + | } | |||
| 68 | + | ||||
| 69 | + | go resizeLoop(sig, out, quit, getSize) | |||
| 70 | + | ||||
| 71 | + | sig <- syscall.SIGWINCH // errors; no emission | |||
| 72 | + | sig <- syscall.SIGWINCH // succeeds | |||
| 73 | + | ||||
| 74 | + | got := <-out | |||
| 75 | + | want := domain.Size{Cols: 10, Rows: 5} | |||
| 76 | + | if got != want { | |||
| 77 | + | t.Fatalf("emitted size = %+v, want %+v", got, want) | |||
| 78 | + | } | |||
| 79 | + | if calls != 2 { | |||
| 80 | + | t.Fatalf("getSize called %d times, want 2", calls) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | close(quit) | |||
| 84 | + | if _, ok := <-out; ok { | |||
| 85 | + | t.Fatalf("expected out to be closed after quit") | |||
| 86 | + | } | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | func TestResizeLoop_QuitClosesOutImmediately(t *testing.T) { | |||
| 90 | + | sig := make(chan os.Signal) | |||
| 91 | + | out := make(chan domain.Size) | |||
| 92 | + | quit := make(chan struct{}) | |||
| 93 | + | ||||
| 94 | + | getSize := func() (domain.Size, error) { | |||
| 95 | + | t.Fatalf("getSize should not be called") | |||
| 96 | + | return domain.Size{}, nil | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | go resizeLoop(sig, out, quit, getSize) | |||
| 100 | + | close(quit) | |||
| 101 | + | ||||
| 102 | + | select { | |||
| 103 | + | case _, ok := <-out: | |||
| 104 | + | if ok { | |||
| 105 | + | t.Fatalf("expected out to be closed, got a value") | |||
| 106 | + | } | |||
| 107 | + | case <-time.After(time.Second): | |||
| 108 | + | t.Fatalf("out was not closed after quit") | |||
| 109 | + | } | |||
| 110 | + | } | |||
| 111 | + | ||||
| 112 | + | func TestDrainSignals_RemovesQueuedSignalsOnly(t *testing.T) { | |||
| 113 | + | sig := make(chan os.Signal, 4) | |||
| 114 | + | sig <- syscall.SIGWINCH | |||
| 115 | + | sig <- syscall.SIGWINCH | |||
| 116 | + | sig <- syscall.SIGWINCH | |||
| 117 | + | ||||
| 118 | + | drainSignals(sig) | |||
| 119 | + | ||||
| 120 | + | select { | |||
| 121 | + | case s := <-sig: | |||
| 122 | + | t.Fatalf("expected sig to be empty after drain, got %v", s) | |||
| 123 | + | default: | |||
| 124 | + | } | |||
| 125 | + | } | |||
A internal/adapters/term/terminal.go +224 -0
| @@ -0,0 +1,224 @@ | |||
| 1 | + | // Package term adapts vev's ports to terminal I/O implementations. | |
| 2 | + | package term | |
| 3 | + | ||
| 4 | + | import ( | |
| 5 | + | "fmt" | |
| 6 | + | "io" | |
| 7 | + | "os" | |
| 8 | + | "os/signal" | |
| 9 | + | "sync" | |
| 10 | + | "syscall" | |
| 11 | + | ||
| 12 | + | "golang.org/x/term" | |
| 13 | + | ||
| 14 | + | "github.com/bnema/vev/internal/domain" | |
| 15 | + | "github.com/bnema/vev/internal/ports" | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | // Compile-time check that Terminal satisfies ports.Terminal. | |
| 19 | + | var _ ports.Terminal = (*Terminal)(nil) | |
| 20 | + | ||
| 21 | + | // Escape sequences for alt-screen and cursor visibility control. All | |
| 22 | + | // emissions go through the batched writer and are explicitly flushed. | |
| 23 | + | const ( | |
| 24 | + | altScreenEnter = "\x1b[?1049h" | |
| 25 | + | altScreenExit = "\x1b[?1049l" | |
| 26 | + | cursorHide = "\x1b[?25l" | |
| 27 | + | cursorShow = "\x1b[?25h" | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | // bufSize is the batched writer's buffer capacity. | |
| 31 | + | const bufSize = 64 * 1024 | |
| 32 | + | ||
| 33 | + | // Terminal implements ports.Terminal for the client-side controlling | |
| 34 | + | // terminal: raw mode, alt-screen, SIGWINCH-driven resize events, and a | |
| 35 | + | // batched stdout writer. | |
| 36 | + | // | |
| 37 | + | // Zero value is not usable; use New or NewWithFiles. | |
| 38 | + | type Terminal struct { | |
| 39 | + | in *os.File | |
| 40 | + | out *os.File | |
| 41 | + | fd int | |
| 42 | + | bw *batchWriter | |
| 43 | + | ||
| 44 | + | mu sync.Mutex | |
| 45 | + | orig *term.State // saved terminal state; nil when raw mode wasn't entered (or was restored) | |
| 46 | + | entered bool // true between a successful EnterRaw and its restore | |
| 47 | + | rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped | |
| 48 | + | restoreFn func() error // the single idempotent restore closure for the current session | |
| 49 | + | ||
| 50 | + | resizeStart sync.Once | |
| 51 | + | resizeStop sync.Once | |
| 52 | + | resizeCh chan domain.Size | |
| 53 | + | resizeQuit chan struct{} | |
| 54 | + | sigCh chan os.Signal | |
| 55 | + | resizeWG sync.WaitGroup | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // New creates a Terminal backed by os.Stdin and os.Stdout. | |
| 59 | + | func New() *Terminal { | |
| 60 | + | return NewWithFiles(os.Stdin, os.Stdout) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // NewWithFiles creates a Terminal backed by the given tty files. It | |
| 64 | + | // exists so tests can inject a PTY pair (or a plain pipe, for the | |
| 65 | + | // non-tty paths) in place of the process's real stdin/stdout. | |
| 66 | + | func NewWithFiles(in, out *os.File) *Terminal { | |
| 67 | + | return &Terminal{ | |
| 68 | + | in: in, | |
| 69 | + | out: out, | |
| 70 | + | fd: int(in.Fd()), | |
| 71 | + | bw: newBatchWriter(out, bufSize), | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // EnterRaw puts the terminal into raw mode (when the input fd is | |
| 76 | + | // actually a tty; otherwise the raw-mode syscalls are skipped and | |
| 77 | + | // recorded, not treated as failure), enters the alt screen, and hides | |
| 78 | + | // the cursor. The returned restore func idempotently reverses all of | |
| 79 | + | // this; it is safe to call multiple times. | |
| 80 | + | func (t *Terminal) EnterRaw() (func() error, error) { | |
| 81 | + | t.mu.Lock() | |
| 82 | + | defer t.mu.Unlock() | |
| 83 | + | ||
| 84 | + | if t.entered { | |
| 85 | + | return t.restoreFn, nil | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | var old *term.State | |
| 89 | + | if term.IsTerminal(t.fd) { | |
| 90 | + | s, err := term.MakeRaw(t.fd) | |
| 91 | + | if err != nil { | |
| 92 | + | return nil, fmt.Errorf("term: make raw: %w", err) | |
| 93 | + | } | |
| 94 | + | old = s | |
| 95 | + | t.rawSkipped = false | |
| 96 | + | } else { | |
| 97 | + | t.rawSkipped = true | |
| 98 | + | } | |
| 99 | + | t.orig = old | |
| 100 | + | ||
| 101 | + | if _, err := t.bw.WriteString(altScreenEnter + cursorHide); err != nil { | |
| 102 | + | t.restoreRawLocked() | |
| 103 | + | return nil, fmt.Errorf("term: enter alt screen: %w", err) | |
| 104 | + | } | |
| 105 | + | if err := t.bw.Flush(); err != nil { | |
| 106 | + | t.restoreRawLocked() | |
| 107 | + | return nil, fmt.Errorf("term: enter alt screen: %w", err) | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | t.entered = true | |
| 111 | + | t.restoreFn = t.makeRestoreLocked() | |
| 112 | + | return t.restoreFn, nil | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | // makeRestoreLocked returns an idempotent restore closure for the | |
| 116 | + | // current EnterRaw "session". Must be called with t.mu held. | |
| 117 | + | func (t *Terminal) makeRestoreLocked() func() error { | |
| 118 | + | var once sync.Once | |
| 119 | + | var err error | |
| 120 | + | return func() error { | |
| 121 | + | once.Do(func() { | |
| 122 | + | err = t.restore() | |
| 123 | + | }) | |
| 124 | + | return err | |
| 125 | + | } | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // restore reverses EnterRaw: it shows the cursor, exits the alt screen, | |
| 129 | + | // restores the original terminal mode (if raw mode was actually | |
| 130 | + | // entered), and stops the resize watcher, closing its channel. | |
| 131 | + | func (t *Terminal) restore() error { | |
| 132 | + | t.mu.Lock() | |
| 133 | + | defer t.mu.Unlock() | |
| 134 | + | ||
| 135 | + | _, werr := t.bw.WriteString(cursorShow + altScreenExit) | |
| 136 | + | ferr := t.bw.Flush() | |
| 137 | + | ||
| 138 | + | rerr := t.restoreRawLocked() | |
| 139 | + | ||
| 140 | + | t.entered = false | |
| 141 | + | t.restoreFn = nil | |
| 142 | + | ||
| 143 | + | t.stopResize() | |
| 144 | + | ||
| 145 | + | switch { | |
| 146 | + | case werr != nil: | |
| 147 | + | return fmt.Errorf("term: restore: write: %w", werr) | |
| 148 | + | case ferr != nil: | |
| 149 | + | return fmt.Errorf("term: restore: flush: %w", ferr) | |
| 150 | + | case rerr != nil: | |
| 151 | + | return fmt.Errorf("term: restore: %w", rerr) | |
| 152 | + | default: | |
| 153 | + | return nil | |
| 154 | + | } | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // restoreRawLocked restores the original terminal mode if it was saved. | |
| 158 | + | // Must be called with t.mu held. | |
| 159 | + | func (t *Terminal) restoreRawLocked() error { | |
| 160 | + | if t.orig == nil { | |
| 161 | + | return nil | |
| 162 | + | } | |
| 163 | + | err := term.Restore(t.fd, t.orig) | |
| 164 | + | t.orig = nil | |
| 165 | + | return err | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | // Size returns the current terminal dimensions. | |
| 169 | + | func (t *Terminal) Size() (domain.Size, error) { | |
| 170 | + | cols, rows, err := term.GetSize(t.fd) | |
| 171 | + | if err != nil { | |
| 172 | + | return domain.Size{}, fmt.Errorf("term: get size: %w", err) | |
| 173 | + | } | |
| 174 | + | return domain.Size{Cols: cols, Rows: rows}, nil | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // ResizeEvents returns a channel of coalesced terminal sizes, one per | |
| 178 | + | // SIGWINCH burst. The SIGWINCH watcher starts lazily on the first call; | |
| 179 | + | // subsequent calls return the same channel. The channel is closed once | |
| 180 | + | // restore (returned by EnterRaw) runs. | |
| 181 | + | func (t *Terminal) ResizeEvents() <-chan domain.Size { | |
| 182 | + | t.resizeStart.Do(func() { | |
| 183 | + | t.resizeCh = make(chan domain.Size) | |
| 184 | + | t.resizeQuit = make(chan struct{}) | |
| 185 | + | t.sigCh = make(chan os.Signal, 1) | |
| 186 | + | signal.Notify(t.sigCh, syscall.SIGWINCH) | |
| 187 | + | ||
| 188 | + | t.resizeWG.Add(1) | |
| 189 | + | go func() { | |
| 190 | + | defer t.resizeWG.Done() | |
| 191 | + | defer signal.Stop(t.sigCh) | |
| 192 | + | resizeLoop(t.sigCh, t.resizeCh, t.resizeQuit, t.Size) | |
| 193 | + | }() | |
| 194 | + | }) | |
| 195 | + | return t.resizeCh | |
| 196 | + | } | |
| 197 | + | ||
| 198 | + | // stopResize stops the SIGWINCH watcher goroutine (if one was started) | |
| 199 | + | // and waits for it to exit, so its channel is guaranteed closed before | |
| 200 | + | // stopResize returns. | |
| 201 | + | func (t *Terminal) stopResize() { | |
| 202 | + | t.resizeStop.Do(func() { | |
| 203 | + | if t.resizeQuit != nil { | |
| 204 | + | close(t.resizeQuit) | |
| 205 | + | } | |
| 206 | + | }) | |
| 207 | + | t.resizeWG.Wait() | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | // In returns the reader for the controlling terminal's input. | |
| 211 | + | func (t *Terminal) In() io.Reader { | |
| 212 | + | return t.in | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // Out returns the batched writer for the controlling terminal's output. | |
| 216 | + | // Call Flush to push buffered bytes to the underlying tty. | |
| 217 | + | func (t *Terminal) Out() io.Writer { | |
| 218 | + | return t.bw | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // Flush writes any buffered output to the terminal device. | |
| 222 | + | func (t *Terminal) Flush() error { | |
| 223 | + | return t.bw.Flush() | |
| 224 | + | } | |
| @@ -0,0 +1,224 @@ | |||||
| 1 | + | // Package term adapts vev's ports to terminal I/O implementations. | |||
| 2 | + | package term | |||
| 3 | + | ||||
| 4 | + | import ( | |||
| 5 | + | "fmt" | |||
| 6 | + | "io" | |||
| 7 | + | "os" | |||
| 8 | + | "os/signal" | |||
| 9 | + | "sync" | |||
| 10 | + | "syscall" | |||
| 11 | + | ||||
| 12 | + | "golang.org/x/term" | |||
| 13 | + | ||||
| 14 | + | "github.com/bnema/vev/internal/domain" | |||
| 15 | + | "github.com/bnema/vev/internal/ports" | |||
| 16 | + | ) | |||
| 17 | + | ||||
| 18 | + | // Compile-time check that Terminal satisfies ports.Terminal. | |||
| 19 | + | var _ ports.Terminal = (*Terminal)(nil) | |||
| 20 | + | ||||
| 21 | + | // Escape sequences for alt-screen and cursor visibility control. All | |||
| 22 | + | // emissions go through the batched writer and are explicitly flushed. | |||
| 23 | + | const ( | |||
| 24 | + | altScreenEnter = "\x1b[?1049h" | |||
| 25 | + | altScreenExit = "\x1b[?1049l" | |||
| 26 | + | cursorHide = "\x1b[?25l" | |||
| 27 | + | cursorShow = "\x1b[?25h" | |||
| 28 | + | ) | |||
| 29 | + | ||||
| 30 | + | // bufSize is the batched writer's buffer capacity. | |||
| 31 | + | const bufSize = 64 * 1024 | |||
| 32 | + | ||||
| 33 | + | // Terminal implements ports.Terminal for the client-side controlling | |||
| 34 | + | // terminal: raw mode, alt-screen, SIGWINCH-driven resize events, and a | |||
| 35 | + | // batched stdout writer. | |||
| 36 | + | // | |||
| 37 | + | // Zero value is not usable; use New or NewWithFiles. | |||
| 38 | + | type Terminal struct { | |||
| 39 | + | in *os.File | |||
| 40 | + | out *os.File | |||
| 41 | + | fd int | |||
| 42 | + | bw *batchWriter | |||
| 43 | + | ||||
| 44 | + | mu sync.Mutex | |||
| 45 | + | orig *term.State // saved terminal state; nil when raw mode wasn't entered (or was restored) | |||
| 46 | + | entered bool // true between a successful EnterRaw and its restore | |||
| 47 | + | rawSkipped bool // true if fd wasn't a tty, so MakeRaw/Restore were skipped | |||
| 48 | + | restoreFn func() error // the single idempotent restore closure for the current session | |||
| 49 | + | ||||
| 50 | + | resizeStart sync.Once | |||
| 51 | + | resizeStop sync.Once | |||
| 52 | + | resizeCh chan domain.Size | |||
| 53 | + | resizeQuit chan struct{} | |||
| 54 | + | sigCh chan os.Signal | |||
| 55 | + | resizeWG sync.WaitGroup | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // New creates a Terminal backed by os.Stdin and os.Stdout. | |||
| 59 | + | func New() *Terminal { | |||
| 60 | + | return NewWithFiles(os.Stdin, os.Stdout) | |||
| 61 | + | } | |||
| 62 | + | ||||
| 63 | + | // NewWithFiles creates a Terminal backed by the given tty files. It | |||
| 64 | + | // exists so tests can inject a PTY pair (or a plain pipe, for the | |||
| 65 | + | // non-tty paths) in place of the process's real stdin/stdout. | |||
| 66 | + | func NewWithFiles(in, out *os.File) *Terminal { | |||
| 67 | + | return &Terminal{ | |||
| 68 | + | in: in, | |||
| 69 | + | out: out, | |||
| 70 | + | fd: int(in.Fd()), | |||
| 71 | + | bw: newBatchWriter(out, bufSize), | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | // EnterRaw puts the terminal into raw mode (when the input fd is | |||
| 76 | + | // actually a tty; otherwise the raw-mode syscalls are skipped and | |||
| 77 | + | // recorded, not treated as failure), enters the alt screen, and hides | |||
| 78 | + | // the cursor. The returned restore func idempotently reverses all of | |||
| 79 | + | // this; it is safe to call multiple times. | |||
| 80 | + | func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 81 | + | t.mu.Lock() | |||
| 82 | + | defer t.mu.Unlock() | |||
| 83 | + | ||||
| 84 | + | if t.entered { | |||
| 85 | + | return t.restoreFn, nil | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | var old *term.State | |||
| 89 | + | if term.IsTerminal(t.fd) { | |||
| 90 | + | s, err := term.MakeRaw(t.fd) | |||
| 91 | + | if err != nil { | |||
| 92 | + | return nil, fmt.Errorf("term: make raw: %w", err) | |||
| 93 | + | } | |||
| 94 | + | old = s | |||
| 95 | + | t.rawSkipped = false | |||
| 96 | + | } else { | |||
| 97 | + | t.rawSkipped = true | |||
| 98 | + | } | |||
| 99 | + | t.orig = old | |||
| 100 | + | ||||
| 101 | + | if _, err := t.bw.WriteString(altScreenEnter + cursorHide); err != nil { | |||
| 102 | + | t.restoreRawLocked() | |||
| 103 | + | return nil, fmt.Errorf("term: enter alt screen: %w", err) | |||
| 104 | + | } | |||
| 105 | + | if err := t.bw.Flush(); err != nil { | |||
| 106 | + | t.restoreRawLocked() | |||
| 107 | + | return nil, fmt.Errorf("term: enter alt screen: %w", err) | |||
| 108 | + | } | |||
| 109 | + | ||||
| 110 | + | t.entered = true | |||
| 111 | + | t.restoreFn = t.makeRestoreLocked() | |||
| 112 | + | return t.restoreFn, nil | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | // makeRestoreLocked returns an idempotent restore closure for the | |||
| 116 | + | // current EnterRaw "session". Must be called with t.mu held. | |||
| 117 | + | func (t *Terminal) makeRestoreLocked() func() error { | |||
| 118 | + | var once sync.Once | |||
| 119 | + | var err error | |||
| 120 | + | return func() error { | |||
| 121 | + | once.Do(func() { | |||
| 122 | + | err = t.restore() | |||
| 123 | + | }) | |||
| 124 | + | return err | |||
| 125 | + | } | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | // restore reverses EnterRaw: it shows the cursor, exits the alt screen, | |||
| 129 | + | // restores the original terminal mode (if raw mode was actually | |||
| 130 | + | // entered), and stops the resize watcher, closing its channel. | |||
| 131 | + | func (t *Terminal) restore() error { | |||
| 132 | + | t.mu.Lock() | |||
| 133 | + | defer t.mu.Unlock() | |||
| 134 | + | ||||
| 135 | + | _, werr := t.bw.WriteString(cursorShow + altScreenExit) | |||
| 136 | + | ferr := t.bw.Flush() | |||
| 137 | + | ||||
| 138 | + | rerr := t.restoreRawLocked() | |||
| 139 | + | ||||
| 140 | + | t.entered = false | |||
| 141 | + | t.restoreFn = nil | |||
| 142 | + | ||||
| 143 | + | t.stopResize() | |||
| 144 | + | ||||
| 145 | + | switch { | |||
| 146 | + | case werr != nil: | |||
| 147 | + | return fmt.Errorf("term: restore: write: %w", werr) | |||
| 148 | + | case ferr != nil: | |||
| 149 | + | return fmt.Errorf("term: restore: flush: %w", ferr) | |||
| 150 | + | case rerr != nil: | |||
| 151 | + | return fmt.Errorf("term: restore: %w", rerr) | |||
| 152 | + | default: | |||
| 153 | + | return nil | |||
| 154 | + | } | |||
| 155 | + | } | |||
| 156 | + | ||||
| 157 | + | // restoreRawLocked restores the original terminal mode if it was saved. | |||
| 158 | + | // Must be called with t.mu held. | |||
| 159 | + | func (t *Terminal) restoreRawLocked() error { | |||
| 160 | + | if t.orig == nil { | |||
| 161 | + | return nil | |||
| 162 | + | } | |||
| 163 | + | err := term.Restore(t.fd, t.orig) | |||
| 164 | + | t.orig = nil | |||
| 165 | + | return err | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | // Size returns the current terminal dimensions. | |||
| 169 | + | func (t *Terminal) Size() (domain.Size, error) { | |||
| 170 | + | cols, rows, err := term.GetSize(t.fd) | |||
| 171 | + | if err != nil { | |||
| 172 | + | return domain.Size{}, fmt.Errorf("term: get size: %w", err) | |||
| 173 | + | } | |||
| 174 | + | return domain.Size{Cols: cols, Rows: rows}, nil | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | // ResizeEvents returns a channel of coalesced terminal sizes, one per | |||
| 178 | + | // SIGWINCH burst. The SIGWINCH watcher starts lazily on the first call; | |||
| 179 | + | // subsequent calls return the same channel. The channel is closed once | |||
| 180 | + | // restore (returned by EnterRaw) runs. | |||
| 181 | + | func (t *Terminal) ResizeEvents() <-chan domain.Size { | |||
| 182 | + | t.resizeStart.Do(func() { | |||
| 183 | + | t.resizeCh = make(chan domain.Size) | |||
| 184 | + | t.resizeQuit = make(chan struct{}) | |||
| 185 | + | t.sigCh = make(chan os.Signal, 1) | |||
| 186 | + | signal.Notify(t.sigCh, syscall.SIGWINCH) | |||
| 187 | + | ||||
| 188 | + | t.resizeWG.Add(1) | |||
| 189 | + | go func() { | |||
| 190 | + | defer t.resizeWG.Done() | |||
| 191 | + | defer signal.Stop(t.sigCh) | |||
| 192 | + | resizeLoop(t.sigCh, t.resizeCh, t.resizeQuit, t.Size) | |||
| 193 | + | }() | |||
| 194 | + | }) | |||
| 195 | + | return t.resizeCh | |||
| 196 | + | } | |||
| 197 | + | ||||
| 198 | + | // stopResize stops the SIGWINCH watcher goroutine (if one was started) | |||
| 199 | + | // and waits for it to exit, so its channel is guaranteed closed before | |||
| 200 | + | // stopResize returns. | |||
| 201 | + | func (t *Terminal) stopResize() { | |||
| 202 | + | t.resizeStop.Do(func() { | |||
| 203 | + | if t.resizeQuit != nil { | |||
| 204 | + | close(t.resizeQuit) | |||
| 205 | + | } | |||
| 206 | + | }) | |||
| 207 | + | t.resizeWG.Wait() | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | // In returns the reader for the controlling terminal's input. | |||
| 211 | + | func (t *Terminal) In() io.Reader { | |||
| 212 | + | return t.in | |||
| 213 | + | } | |||
| 214 | + | ||||
| 215 | + | // Out returns the batched writer for the controlling terminal's output. | |||
| 216 | + | // Call Flush to push buffered bytes to the underlying tty. | |||
| 217 | + | func (t *Terminal) Out() io.Writer { | |||
| 218 | + | return t.bw | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | // Flush writes any buffered output to the terminal device. | |||
| 222 | + | func (t *Terminal) Flush() error { | |||
| 223 | + | return t.bw.Flush() | |||
| 224 | + | } | |||
A internal/adapters/term/terminal_test.go +198 -0
| @@ -0,0 +1,198 @@ | |||
| 1 | + | package term | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "io" | |
| 6 | + | "os" | |
| 7 | + | "testing" | |
| 8 | + | "time" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // pipePair returns an os.Pipe and a goroutine that continuously copies | |
| 12 | + | // everything written to the write end into captured, closing done once | |
| 13 | + | // the write end is closed (EOF). | |
| 14 | + | func pipeCapture(t *testing.T) (r, w *os.File, captured *bytes.Buffer, done chan struct{}) { | |
| 15 | + | t.Helper() | |
| 16 | + | r, w, err := os.Pipe() | |
| 17 | + | if err != nil { | |
| 18 | + | t.Fatalf("os.Pipe: %v", err) | |
| 19 | + | } | |
| 20 | + | captured = &bytes.Buffer{} | |
| 21 | + | done = make(chan struct{}) | |
| 22 | + | go func() { | |
| 23 | + | _, _ = io.Copy(captured, r) | |
| 24 | + | close(done) | |
| 25 | + | }() | |
| 26 | + | return r, w, captured, done | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |
| 30 | + | inR, inW, err := os.Pipe() | |
| 31 | + | if err != nil { | |
| 32 | + | t.Fatalf("os.Pipe(in): %v", err) | |
| 33 | + | } | |
| 34 | + | defer inW.Close() | |
| 35 | + | defer inR.Close() | |
| 36 | + | ||
| 37 | + | outR, outW, captured, done := pipeCapture(t) | |
| 38 | + | defer outR.Close() | |
| 39 | + | ||
| 40 | + | tm := NewWithFiles(inR, outW) | |
| 41 | + | ||
| 42 | + | restore, err := tm.EnterRaw() | |
| 43 | + | if err != nil { | |
| 44 | + | t.Fatalf("EnterRaw: %v", err) | |
| 45 | + | } | |
| 46 | + | if !tm.rawSkipped { | |
| 47 | + | t.Fatalf("expected rawSkipped=true for a non-tty fd") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | if err := restore(); err != nil { | |
| 51 | + | t.Fatalf("restore: %v", err) | |
| 52 | + | } | |
| 53 | + | // Idempotent: calling again must not error or duplicate output. | |
| 54 | + | if err := restore(); err != nil { | |
| 55 | + | t.Fatalf("second restore call: %v", err) | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | outW.Close() | |
| 59 | + | <-done | |
| 60 | + | ||
| 61 | + | want := altScreenEnter + cursorHide + cursorShow + altScreenExit | |
| 62 | + | if got := captured.String(); got != want { | |
| 63 | + | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 64 | + | } | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |
| 68 | + | inR, inW, err := os.Pipe() | |
| 69 | + | if err != nil { | |
| 70 | + | t.Fatalf("os.Pipe(in): %v", err) | |
| 71 | + | } | |
| 72 | + | defer inW.Close() | |
| 73 | + | defer inR.Close() | |
| 74 | + | ||
| 75 | + | outR, outW, captured, done := pipeCapture(t) | |
| 76 | + | defer outR.Close() | |
| 77 | + | ||
| 78 | + | tm := NewWithFiles(inR, outW) | |
| 79 | + | ||
| 80 | + | restore1, err := tm.EnterRaw() | |
| 81 | + | if err != nil { | |
| 82 | + | t.Fatalf("EnterRaw #1: %v", err) | |
| 83 | + | } | |
| 84 | + | restore2, err := tm.EnterRaw() | |
| 85 | + | if err != nil { | |
| 86 | + | t.Fatalf("EnterRaw #2: %v", err) | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | if err := restore1(); err != nil { | |
| 90 | + | t.Fatalf("restore1: %v", err) | |
| 91 | + | } | |
| 92 | + | if err := restore2(); err != nil { | |
| 93 | + | t.Fatalf("restore2: %v", err) | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | outW.Close() | |
| 97 | + | <-done | |
| 98 | + | ||
| 99 | + | // Alt-screen/cursor escapes must appear exactly once for enter and | |
| 100 | + | // once for exit, regardless of how many times EnterRaw/restore were | |
| 101 | + | // called. | |
| 102 | + | want := altScreenEnter + cursorHide + cursorShow + altScreenExit | |
| 103 | + | if got := captured.String(); got != want { | |
| 104 | + | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | func TestTerminal_InOut(t *testing.T) { | |
| 109 | + | inR, inW, err := os.Pipe() | |
| 110 | + | if err != nil { | |
| 111 | + | t.Fatalf("os.Pipe(in): %v", err) | |
| 112 | + | } | |
| 113 | + | defer inW.Close() | |
| 114 | + | defer inR.Close() | |
| 115 | + | ||
| 116 | + | outR, outW, captured, done := pipeCapture(t) | |
| 117 | + | defer outR.Close() | |
| 118 | + | ||
| 119 | + | tm := NewWithFiles(inR, outW) | |
| 120 | + | ||
| 121 | + | if tm.In() == nil { | |
| 122 | + | t.Fatalf("In() = nil") | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | if _, err := tm.Out().Write([]byte("payload")); err != nil { | |
| 126 | + | t.Fatalf("Out().Write: %v", err) | |
| 127 | + | } | |
| 128 | + | if err := tm.Flush(); err != nil { | |
| 129 | + | t.Fatalf("Flush: %v", err) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | outW.Close() | |
| 133 | + | <-done | |
| 134 | + | ||
| 135 | + | if got := captured.String(); got != "payload" { | |
| 136 | + | t.Fatalf("captured = %q, want %q", got, "payload") | |
| 137 | + | } | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | func TestTerminal_ResizeEvents_ReturnsSameChannelAndClosesOnRestore(t *testing.T) { | |
| 141 | + | inR, inW, err := os.Pipe() | |
| 142 | + | if err != nil { | |
| 143 | + | t.Fatalf("os.Pipe(in): %v", err) | |
| 144 | + | } | |
| 145 | + | defer inW.Close() | |
| 146 | + | defer inR.Close() | |
| 147 | + | ||
| 148 | + | outR, outW, _, done := pipeCapture(t) | |
| 149 | + | defer outR.Close() | |
| 150 | + | ||
| 151 | + | tm := NewWithFiles(inR, outW) | |
| 152 | + | ||
| 153 | + | restore, err := tm.EnterRaw() | |
| 154 | + | if err != nil { | |
| 155 | + | t.Fatalf("EnterRaw: %v", err) | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | ch1 := tm.ResizeEvents() | |
| 159 | + | ch2 := tm.ResizeEvents() | |
| 160 | + | if ch1 != ch2 { | |
| 161 | + | t.Fatalf("ResizeEvents() returned different channels across calls") | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | if err := restore(); err != nil { | |
| 165 | + | t.Fatalf("restore: %v", err) | |
| 166 | + | } | |
| 167 | + | ||
| 168 | + | select { | |
| 169 | + | case _, ok := <-ch1: | |
| 170 | + | if ok { | |
| 171 | + | t.Fatalf("expected resize channel to be closed after restore") | |
| 172 | + | } | |
| 173 | + | case <-time.After(2 * time.Second): | |
| 174 | + | t.Fatalf("resize channel was not closed after restore") | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | outW.Close() | |
| 178 | + | <-done | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | func TestTerminal_Size_NonTTY_ReturnsError(t *testing.T) { | |
| 182 | + | inR, inW, err := os.Pipe() | |
| 183 | + | if err != nil { | |
| 184 | + | t.Fatalf("os.Pipe(in): %v", err) | |
| 185 | + | } | |
| 186 | + | defer inW.Close() | |
| 187 | + | defer inR.Close() | |
| 188 | + | ||
| 189 | + | outR, outW, _, _ := pipeCapture(t) | |
| 190 | + | defer outR.Close() | |
| 191 | + | defer outW.Close() | |
| 192 | + | ||
| 193 | + | tm := NewWithFiles(inR, outW) | |
| 194 | + | ||
| 195 | + | if _, err := tm.Size(); err == nil { | |
| 196 | + | t.Fatalf("expected Size() to error on a non-tty fd") | |
| 197 | + | } | |
| 198 | + | } | |
| @@ -0,0 +1,198 @@ | |||||
| 1 | + | package term | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "io" | |||
| 6 | + | "os" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // pipePair returns an os.Pipe and a goroutine that continuously copies | |||
| 12 | + | // everything written to the write end into captured, closing done once | |||
| 13 | + | // the write end is closed (EOF). | |||
| 14 | + | func pipeCapture(t *testing.T) (r, w *os.File, captured *bytes.Buffer, done chan struct{}) { | |||
| 15 | + | t.Helper() | |||
| 16 | + | r, w, err := os.Pipe() | |||
| 17 | + | if err != nil { | |||
| 18 | + | t.Fatalf("os.Pipe: %v", err) | |||
| 19 | + | } | |||
| 20 | + | captured = &bytes.Buffer{} | |||
| 21 | + | done = make(chan struct{}) | |||
| 22 | + | go func() { | |||
| 23 | + | _, _ = io.Copy(captured, r) | |||
| 24 | + | close(done) | |||
| 25 | + | }() | |||
| 26 | + | return r, w, captured, done | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||
| 30 | + | inR, inW, err := os.Pipe() | |||
| 31 | + | if err != nil { | |||
| 32 | + | t.Fatalf("os.Pipe(in): %v", err) | |||
| 33 | + | } | |||
| 34 | + | defer inW.Close() | |||
| 35 | + | defer inR.Close() | |||
| 36 | + | ||||
| 37 | + | outR, outW, captured, done := pipeCapture(t) | |||
| 38 | + | defer outR.Close() | |||
| 39 | + | ||||
| 40 | + | tm := NewWithFiles(inR, outW) | |||
| 41 | + | ||||
| 42 | + | restore, err := tm.EnterRaw() | |||
| 43 | + | if err != nil { | |||
| 44 | + | t.Fatalf("EnterRaw: %v", err) | |||
| 45 | + | } | |||
| 46 | + | if !tm.rawSkipped { | |||
| 47 | + | t.Fatalf("expected rawSkipped=true for a non-tty fd") | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | if err := restore(); err != nil { | |||
| 51 | + | t.Fatalf("restore: %v", err) | |||
| 52 | + | } | |||
| 53 | + | // Idempotent: calling again must not error or duplicate output. | |||
| 54 | + | if err := restore(); err != nil { | |||
| 55 | + | t.Fatalf("second restore call: %v", err) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | outW.Close() | |||
| 59 | + | <-done | |||
| 60 | + | ||||
| 61 | + | want := altScreenEnter + cursorHide + cursorShow + altScreenExit | |||
| 62 | + | if got := captured.String(); got != want { | |||
| 63 | + | t.Fatalf("captured escapes = %q, want %q", got, want) | |||
| 64 | + | } | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||
| 68 | + | inR, inW, err := os.Pipe() | |||
| 69 | + | if err != nil { | |||
| 70 | + | t.Fatalf("os.Pipe(in): %v", err) | |||
| 71 | + | } | |||
| 72 | + | defer inW.Close() | |||
| 73 | + | defer inR.Close() | |||
| 74 | + | ||||
| 75 | + | outR, outW, captured, done := pipeCapture(t) | |||
| 76 | + | defer outR.Close() | |||
| 77 | + | ||||
| 78 | + | tm := NewWithFiles(inR, outW) | |||
| 79 | + | ||||
| 80 | + | restore1, err := tm.EnterRaw() | |||
| 81 | + | if err != nil { | |||
| 82 | + | t.Fatalf("EnterRaw #1: %v", err) | |||
| 83 | + | } | |||
| 84 | + | restore2, err := tm.EnterRaw() | |||
| 85 | + | if err != nil { | |||
| 86 | + | t.Fatalf("EnterRaw #2: %v", err) | |||
| 87 | + | } | |||
| 88 | + | ||||
| 89 | + | if err := restore1(); err != nil { | |||
| 90 | + | t.Fatalf("restore1: %v", err) | |||
| 91 | + | } | |||
| 92 | + | if err := restore2(); err != nil { | |||
| 93 | + | t.Fatalf("restore2: %v", err) | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | outW.Close() | |||
| 97 | + | <-done | |||
| 98 | + | ||||
| 99 | + | // Alt-screen/cursor escapes must appear exactly once for enter and | |||
| 100 | + | // once for exit, regardless of how many times EnterRaw/restore were | |||
| 101 | + | // called. | |||
| 102 | + | want := altScreenEnter + cursorHide + cursorShow + altScreenExit | |||
| 103 | + | if got := captured.String(); got != want { | |||
| 104 | + | t.Fatalf("captured escapes = %q, want %q", got, want) | |||
| 105 | + | } | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | func TestTerminal_InOut(t *testing.T) { | |||
| 109 | + | inR, inW, err := os.Pipe() | |||
| 110 | + | if err != nil { | |||
| 111 | + | t.Fatalf("os.Pipe(in): %v", err) | |||
| 112 | + | } | |||
| 113 | + | defer inW.Close() | |||
| 114 | + | defer inR.Close() | |||
| 115 | + | ||||
| 116 | + | outR, outW, captured, done := pipeCapture(t) | |||
| 117 | + | defer outR.Close() | |||
| 118 | + | ||||
| 119 | + | tm := NewWithFiles(inR, outW) | |||
| 120 | + | ||||
| 121 | + | if tm.In() == nil { | |||
| 122 | + | t.Fatalf("In() = nil") | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | if _, err := tm.Out().Write([]byte("payload")); err != nil { | |||
| 126 | + | t.Fatalf("Out().Write: %v", err) | |||
| 127 | + | } | |||
| 128 | + | if err := tm.Flush(); err != nil { | |||
| 129 | + | t.Fatalf("Flush: %v", err) | |||
| 130 | + | } | |||
| 131 | + | ||||
| 132 | + | outW.Close() | |||
| 133 | + | <-done | |||
| 134 | + | ||||
| 135 | + | if got := captured.String(); got != "payload" { | |||
| 136 | + | t.Fatalf("captured = %q, want %q", got, "payload") | |||
| 137 | + | } | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | func TestTerminal_ResizeEvents_ReturnsSameChannelAndClosesOnRestore(t *testing.T) { | |||
| 141 | + | inR, inW, err := os.Pipe() | |||
| 142 | + | if err != nil { | |||
| 143 | + | t.Fatalf("os.Pipe(in): %v", err) | |||
| 144 | + | } | |||
| 145 | + | defer inW.Close() | |||
| 146 | + | defer inR.Close() | |||
| 147 | + | ||||
| 148 | + | outR, outW, _, done := pipeCapture(t) | |||
| 149 | + | defer outR.Close() | |||
| 150 | + | ||||
| 151 | + | tm := NewWithFiles(inR, outW) | |||
| 152 | + | ||||
| 153 | + | restore, err := tm.EnterRaw() | |||
| 154 | + | if err != nil { | |||
| 155 | + | t.Fatalf("EnterRaw: %v", err) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | ch1 := tm.ResizeEvents() | |||
| 159 | + | ch2 := tm.ResizeEvents() | |||
| 160 | + | if ch1 != ch2 { | |||
| 161 | + | t.Fatalf("ResizeEvents() returned different channels across calls") | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | if err := restore(); err != nil { | |||
| 165 | + | t.Fatalf("restore: %v", err) | |||
| 166 | + | } | |||
| 167 | + | ||||
| 168 | + | select { | |||
| 169 | + | case _, ok := <-ch1: | |||
| 170 | + | if ok { | |||
| 171 | + | t.Fatalf("expected resize channel to be closed after restore") | |||
| 172 | + | } | |||
| 173 | + | case <-time.After(2 * time.Second): | |||
| 174 | + | t.Fatalf("resize channel was not closed after restore") | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | outW.Close() | |||
| 178 | + | <-done | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func TestTerminal_Size_NonTTY_ReturnsError(t *testing.T) { | |||
| 182 | + | inR, inW, err := os.Pipe() | |||
| 183 | + | if err != nil { | |||
| 184 | + | t.Fatalf("os.Pipe(in): %v", err) | |||
| 185 | + | } | |||
| 186 | + | defer inW.Close() | |||
| 187 | + | defer inR.Close() | |||
| 188 | + | ||||
| 189 | + | outR, outW, _, _ := pipeCapture(t) | |||
| 190 | + | defer outR.Close() | |||
| 191 | + | defer outW.Close() | |||
| 192 | + | ||||
| 193 | + | tm := NewWithFiles(inR, outW) | |||
| 194 | + | ||||
| 195 | + | if _, err := tm.Size(); err == nil { | |||
| 196 | + | t.Fatalf("expected Size() to error on a non-tty fd") | |||
| 197 | + | } | |||
| 198 | + | } | |||