Merge pull request #134 from bnema/fix/disable-autowrap-alt-screen
fix(term): disable autowrap while vev owns the alt screen
3 files · +214 -5
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 + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 92 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + 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 + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | 92 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + 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 +34 -2
| @@ -31,8 +31,24 @@ const ( | |||
| 31 | 31 | bracketedPasteDisable = "\x1b[?2004l" | |
| 32 | 32 | colorSchemeEnable = "\x1b[?2031h" | |
| 33 | 33 | colorSchemeDisable = "\x1b[?2031l" | |
| 34 | + | // DECAWM. vev addresses every row explicitly and never relies on the | |
| 35 | + | // terminal wrapping its output, so autowrap stays off while it owns the | |
| 36 | + | // alt screen: a frame the daemon composed for a wider terminal is then | |
| 37 | + | // clipped at the right margin instead of bleeding a styled run onto the | |
| 38 | + | // next row. Restored alongside the other modes, since DECAWM is global | |
| 39 | + | // and is not saved by the alt-screen switch. | |
| 40 | + | autowrapDisable = "\x1b[?7l" | |
| 41 | + | autowrapEnable = "\x1b[?7h" | |
| 34 | 42 | ) | |
| 35 | 43 | ||
| 44 | + | // visualRestore turns off every mode EnterRaw turns on, in reverse order, and | |
| 45 | + | // hands the normal screen back. Shared by the ordinary restore and the direct | |
| 46 | + | // fallback so the two can never drift: a partial emission may have left any | |
| 47 | + | // prefix of the enter sequence in effect, so the reset has to cover all of it. | |
| 48 | + | // Every mode reset here is idempotent. | |
| 49 | + | const visualRestore = cursorShow + cursorStyleDefault + mouseDisable + | |
| 50 | + | bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |
| 51 | + | ||
| 36 | 52 | // bufSize is the batched writer's buffer capacity. | |
| 37 | 53 | const bufSize = 64 * 1024 | |
| 38 | 54 | ||
| @@ -107,11 +123,13 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 107 | 123 | } | |
| 108 | 124 | t.orig = old | |
| 109 | 125 | ||
| 110 | - | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil { | |
| 126 | + | if _, err := t.bw.WriteString(altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil { | |
| 127 | + | t.resetVisualModesDirect() | |
| 111 | 128 | _ = t.restoreRawLocked() | |
| 112 | 129 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | |
| 113 | 130 | } | |
| 114 | 131 | if err := t.bw.Flush(); err != nil { | |
| 132 | + | t.resetVisualModesDirect() | |
| 115 | 133 | _ = t.restoreRawLocked() | |
| 116 | 134 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | |
| 117 | 135 | } | |
| @@ -121,6 +139,17 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||
| 121 | 139 | return t.restoreFn, nil | |
| 122 | 140 | } | |
| 123 | 141 | ||
| 142 | + | // resetVisualModesDirect writes visualRestore straight to the descriptor | |
| 143 | + | // instead of through the batched writer. It is the fallback for a writer that | |
| 144 | + | // has already failed: any prefix of the enter sequence may have reached the | |
| 145 | + | // terminal, and a hidden cursor, live mouse reporting or the alt screen would | |
| 146 | + | // outlive the process. Replaying the resets when nothing was emitted is | |
| 147 | + | // harmless. Best effort by construction — the descriptor is usually broken | |
| 148 | + | // too. Must be called with t.mu held. | |
| 149 | + | func (t *Terminal) resetVisualModesDirect() { | |
| 150 | + | _, _ = t.out.WriteString(visualRestore) | |
| 151 | + | } | |
| 152 | + | ||
| 124 | 153 | // makeRestoreLocked returns an idempotent restore closure for the | |
| 125 | 154 | // current EnterRaw "session". Must be called with t.mu held. | |
| 126 | 155 | func (t *Terminal) makeRestoreLocked() func() error { | |
| @@ -141,8 +170,11 @@ func (t *Terminal) restore() error { | |||
| 141 | 170 | t.mu.Lock() | |
| 142 | 171 | defer t.mu.Unlock() | |
| 143 | 172 | ||
| 144 | - | _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit) | |
| 173 | + | _, werr := t.bw.WriteString(visualRestore) | |
| 145 | 174 | ferr := t.bw.Flush() | |
| 175 | + | if werr != nil || ferr != nil { | |
| 176 | + | t.resetVisualModesDirect() | |
| 177 | + | } | |
| 146 | 178 | ||
| 147 | 179 | rerr := t.restoreRawLocked() | |
| 148 | 180 | ||
| @@ -31,8 +31,24 @@ 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 | + | // DECAWM. vev addresses every row explicitly and never relies on the | |||
| 35 | + | // terminal wrapping its output, so autowrap stays off while it owns the | |||
| 36 | + | // alt screen: a frame the daemon composed for a wider terminal is then | |||
| 37 | + | // clipped at the right margin instead of bleeding a styled run onto the | |||
| 38 | + | // next row. Restored alongside the other modes, since DECAWM is global | |||
| 39 | + | // and is not saved by the alt-screen switch. | |||
| 40 | + | autowrapDisable = "\x1b[?7l" | |||
| 41 | + | autowrapEnable = "\x1b[?7h" | |||
| 34 | ) | 42 | ) | ||
| 35 | 43 | ||||
| 44 | + | // visualRestore turns off every mode EnterRaw turns on, in reverse order, and | |||
| 45 | + | // hands the normal screen back. Shared by the ordinary restore and the direct | |||
| 46 | + | // fallback so the two can never drift: a partial emission may have left any | |||
| 47 | + | // prefix of the enter sequence in effect, so the reset has to cover all of it. | |||
| 48 | + | // Every mode reset here is idempotent. | |||
| 49 | + | const visualRestore = cursorShow + cursorStyleDefault + mouseDisable + | |||
| 50 | + | bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |||
| 51 | + | ||||
| 36 | // bufSize is the batched writer's buffer capacity. | 52 | // bufSize is the batched writer's buffer capacity. | ||
| 37 | const bufSize = 64 * 1024 | 53 | const bufSize = 64 * 1024 | ||
| 38 | 54 | ||||
| @@ -107,11 +123,13 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||||
| 107 | } | 123 | } | ||
| 108 | t.orig = old | 124 | t.orig = old | ||
| 109 | 125 | ||||
| 110 | - | if _, err := t.bw.WriteString(altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil { | 126 | + | if _, err := t.bw.WriteString(altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable); err != nil { |
| 127 | + | t.resetVisualModesDirect() | |||
| 111 | _ = t.restoreRawLocked() | 128 | _ = t.restoreRawLocked() | ||
| 112 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | 129 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | ||
| 113 | } | 130 | } | ||
| 114 | if err := t.bw.Flush(); err != nil { | 131 | if err := t.bw.Flush(); err != nil { | ||
| 132 | + | t.resetVisualModesDirect() | |||
| 115 | _ = t.restoreRawLocked() | 133 | _ = t.restoreRawLocked() | ||
| 116 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | 134 | return nil, fmt.Errorf("term: enter alt screen: %w", err) | ||
| 117 | } | 135 | } | ||
| @@ -121,6 +139,17 @@ func (t *Terminal) EnterRaw() (func() error, error) { | |||||
| 121 | return t.restoreFn, nil | 139 | return t.restoreFn, nil | ||
| 122 | } | 140 | } | ||
| 123 | 141 | ||||
| 142 | + | // resetVisualModesDirect writes visualRestore straight to the descriptor | |||
| 143 | + | // instead of through the batched writer. It is the fallback for a writer that | |||
| 144 | + | // has already failed: any prefix of the enter sequence may have reached the | |||
| 145 | + | // terminal, and a hidden cursor, live mouse reporting or the alt screen would | |||
| 146 | + | // outlive the process. Replaying the resets when nothing was emitted is | |||
| 147 | + | // harmless. Best effort by construction — the descriptor is usually broken | |||
| 148 | + | // too. Must be called with t.mu held. | |||
| 149 | + | func (t *Terminal) resetVisualModesDirect() { | |||
| 150 | + | _, _ = t.out.WriteString(visualRestore) | |||
| 151 | + | } | |||
| 152 | + | ||||
| 124 | // makeRestoreLocked returns an idempotent restore closure for the | 153 | // makeRestoreLocked returns an idempotent restore closure for the | ||
| 125 | // current EnterRaw "session". Must be called with t.mu held. | 154 | // current EnterRaw "session". Must be called with t.mu held. | ||
| 126 | func (t *Terminal) makeRestoreLocked() func() error { | 155 | func (t *Terminal) makeRestoreLocked() func() error { | ||
| @@ -141,8 +170,11 @@ func (t *Terminal) restore() error { | |||||
| 141 | t.mu.Lock() | 170 | t.mu.Lock() | ||
| 142 | defer t.mu.Unlock() | 171 | defer t.mu.Unlock() | ||
| 143 | 172 | ||||
| 144 | - | _, werr := t.bw.WriteString(cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit) | 173 | + | _, werr := t.bw.WriteString(visualRestore) |
| 145 | ferr := t.bw.Flush() | 174 | ferr := t.bw.Flush() | ||
| 175 | + | if werr != nil || ferr != nil { | |||
| 176 | + | t.resetVisualModesDirect() | |||
| 177 | + | } | |||
| 146 | 178 | ||||
| 147 | rerr := t.restoreRawLocked() | 179 | rerr := t.restoreRawLocked() | ||
| 148 | 180 | ||||
M internal/adapters/term/terminal_test.go +179 -2
| @@ -2,8 +2,10 @@ package term | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "bytes" | |
| 5 | + | "errors" | |
| 5 | 6 | "io" | |
| 6 | 7 | "os" | |
| 8 | + | "strings" | |
| 7 | 9 | "testing" | |
| 8 | 10 | "time" | |
| 9 | 11 | ||
| @@ -60,7 +62,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||
| 60 | 62 | _ = outW.Close() | |
| 61 | 63 | <-done | |
| 62 | 64 | ||
| 63 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 65 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |
| 64 | 66 | if got := captured.String(); got != want { | |
| 65 | 67 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 66 | 68 | } | |
| @@ -69,6 +71,181 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||
| 69 | 71 | } | |
| 70 | 72 | } | |
| 71 | 73 | ||
| 74 | + | // A frame the daemon composed for a wider terminal must never bleed onto the | |
| 75 | + | // next row: with DECAWM left on, an over-long styled run (the full-width tab | |
| 76 | + | // bar) wraps and paints a second row in the accent colour. vev positions every | |
| 77 | + | // row explicitly and never relies on the terminal wrapping for it, so autowrap | |
| 78 | + | // stays off for as long as vev owns the alt screen. | |
| 79 | + | func TestTerminal_EnterRaw_DisablesAutowrapInsideAltScreen(t *testing.T) { | |
| 80 | + | const ( | |
| 81 | + | autowrapOff = "\x1b[?7l" | |
| 82 | + | autowrapOn = "\x1b[?7h" | |
| 83 | + | altEnter = "\x1b[?1049h" | |
| 84 | + | altExit = "\x1b[?1049l" | |
| 85 | + | ) | |
| 86 | + | ||
| 87 | + | inR, inW, err := os.Pipe() | |
| 88 | + | if err != nil { | |
| 89 | + | t.Fatalf("os.Pipe(in): %v", err) | |
| 90 | + | } | |
| 91 | + | defer func() { _ = inW.Close() }() | |
| 92 | + | defer func() { _ = inR.Close() }() | |
| 93 | + | ||
| 94 | + | outR, outW, captured, done := pipeCapture(t) | |
| 95 | + | defer func() { _ = outR.Close() }() | |
| 96 | + | ||
| 97 | + | tm := NewWithFiles(inR, outW) | |
| 98 | + | ||
| 99 | + | restore, err := tm.EnterRaw() | |
| 100 | + | if err != nil { | |
| 101 | + | t.Fatalf("EnterRaw: %v", err) | |
| 102 | + | } | |
| 103 | + | if err := restore(); err != nil { | |
| 104 | + | t.Fatalf("restore: %v", err) | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | _ = outW.Close() | |
| 108 | + | <-done | |
| 109 | + | got := captured.String() | |
| 110 | + | ||
| 111 | + | for _, tc := range []struct { | |
| 112 | + | name string | |
| 113 | + | seq string | |
| 114 | + | }{ | |
| 115 | + | {"autowrap disabled exactly once", autowrapOff}, | |
| 116 | + | {"autowrap restored exactly once", autowrapOn}, | |
| 117 | + | } { | |
| 118 | + | t.Run(tc.name, func(t *testing.T) { | |
| 119 | + | if n := strings.Count(got, tc.seq); n != 1 { | |
| 120 | + | t.Fatalf("emitted %q %d times, want exactly 1; full output %q", tc.seq, n, got) | |
| 121 | + | } | |
| 122 | + | }) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // Ordering: disabling autowrap must land inside the alt screen, and it | |
| 126 | + | // must be restored before vev hands the screen back to the shell. | |
| 127 | + | for _, tc := range []struct { | |
| 128 | + | name string | |
| 129 | + | before string | |
| 130 | + | after string | |
| 131 | + | }{ | |
| 132 | + | {"alt screen entered before autowrap is disabled", altEnter, autowrapOff}, | |
| 133 | + | {"autowrap disabled before it is restored", autowrapOff, autowrapOn}, | |
| 134 | + | {"autowrap restored before leaving the alt screen", autowrapOn, altExit}, | |
| 135 | + | } { | |
| 136 | + | t.Run(tc.name, func(t *testing.T) { | |
| 137 | + | i, j := strings.Index(got, tc.before), strings.Index(got, tc.after) | |
| 138 | + | if i < 0 || j < 0 { | |
| 139 | + | t.Fatalf("missing sequence: %q at %d, %q at %d; full output %q", tc.before, i, tc.after, j, got) | |
| 140 | + | } | |
| 141 | + | if i >= j { | |
| 142 | + | t.Fatalf("%q (index %d) must precede %q (index %d); full output %q", tc.before, i, tc.after, j, got) | |
| 143 | + | } | |
| 144 | + | }) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // prefixThenFailWriter forwards at most prefix bytes to sink and then reports | |
| 149 | + | // a failure, standing in for a tty that accepts part of a mode sequence and | |
| 150 | + | // then stops. A prefix of 0 fails without emitting anything. | |
| 151 | + | type prefixThenFailWriter struct { | |
| 152 | + | sink io.Writer | |
| 153 | + | prefix int | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | func (w *prefixThenFailWriter) Write(p []byte) (int, error) { | |
| 157 | + | if len(p) > w.prefix { | |
| 158 | + | p = p[:w.prefix] | |
| 159 | + | } | |
| 160 | + | n := 0 | |
| 161 | + | if len(p) > 0 { | |
| 162 | + | var err error | |
| 163 | + | n, err = w.sink.Write(p) | |
| 164 | + | if err != nil { | |
| 165 | + | return n, err | |
| 166 | + | } | |
| 167 | + | } | |
| 168 | + | w.prefix -= n | |
| 169 | + | return n, errors.New("sink failed after prefix") | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // When the batched writer fails, part of the mode sequence may already have | |
| 173 | + | // reached the terminal, so the visual modes must be reset straight to the fd | |
| 174 | + | // rather than through the writer that just failed. Poisoning tm.bw while | |
| 175 | + | // leaving tm.out intact isolates that fallback: the buffered path fails, the | |
| 176 | + | // real descriptor still records what the fallback attempted. | |
| 177 | + | func TestTerminal_WriteFailure_AttemptsVisualResetOnFd(t *testing.T) { | |
| 178 | + | // Every mode EnterRaw can turn on has to be turned back off, not just the | |
| 179 | + | // alt screen: a partial emission that got as far as cursorHide/mouseEnable | |
| 180 | + | // would otherwise leave the cursor hidden and mouse reporting on. | |
| 181 | + | const wantCleanup = cursorShow + cursorStyleDefault + mouseDisable + | |
| 182 | + | bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |
| 183 | + | const enterSequence = altScreenEnter + autowrapDisable + cursorHide + | |
| 184 | + | mouseEnable + bracketedPasteEnable + colorSchemeEnable | |
| 185 | + | ||
| 186 | + | for _, tc := range []struct { | |
| 187 | + | name string | |
| 188 | + | prefix int // bytes the sink accepts before failing | |
| 189 | + | poisonAfter bool // poison the writer after EnterRaw, i.e. fail during restore | |
| 190 | + | want string | |
| 191 | + | }{ | |
| 192 | + | { | |
| 193 | + | name: "enter fails before emitting anything", | |
| 194 | + | want: wantCleanup, | |
| 195 | + | }, | |
| 196 | + | { | |
| 197 | + | name: "enter fails after emitting part of the sequence", | |
| 198 | + | prefix: len(altScreenEnter + autowrapDisable + cursorHide), | |
| 199 | + | want: altScreenEnter + autowrapDisable + cursorHide + wantCleanup, | |
| 200 | + | }, | |
| 201 | + | { | |
| 202 | + | name: "restore fails after a successful enter", | |
| 203 | + | poisonAfter: true, | |
| 204 | + | want: enterSequence + wantCleanup, | |
| 205 | + | }, | |
| 206 | + | } { | |
| 207 | + | t.Run(tc.name, func(t *testing.T) { | |
| 208 | + | inR, inW, err := os.Pipe() | |
| 209 | + | if err != nil { | |
| 210 | + | t.Fatalf("os.Pipe(in): %v", err) | |
| 211 | + | } | |
| 212 | + | defer func() { _ = inW.Close() }() | |
| 213 | + | defer func() { _ = inR.Close() }() | |
| 214 | + | ||
| 215 | + | outR, outW, captured, done := pipeCapture(t) | |
| 216 | + | defer func() { _ = outR.Close() }() | |
| 217 | + | ||
| 218 | + | tm := NewWithFiles(inR, outW) | |
| 219 | + | poison := func() { | |
| 220 | + | tm.bw = newBatchWriter(&prefixThenFailWriter{sink: outW, prefix: tc.prefix}, bufSize) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | if !tc.poisonAfter { | |
| 224 | + | poison() | |
| 225 | + | if _, err := tm.EnterRaw(); err == nil { | |
| 226 | + | t.Fatalf("EnterRaw returned nil error on a failing sink") | |
| 227 | + | } | |
| 228 | + | } else { | |
| 229 | + | restore, err := tm.EnterRaw() | |
| 230 | + | if err != nil { | |
| 231 | + | t.Fatalf("EnterRaw: %v", err) | |
| 232 | + | } | |
| 233 | + | poison() | |
| 234 | + | if err := restore(); err == nil { | |
| 235 | + | t.Fatalf("restore returned nil error on a failing sink") | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | _ = outW.Close() | |
| 240 | + | <-done | |
| 241 | + | ||
| 242 | + | if got := captured.String(); got != tc.want { | |
| 243 | + | t.Fatalf("bytes written to the fd = %q, want %q", got, tc.want) | |
| 244 | + | } | |
| 245 | + | }) | |
| 246 | + | } | |
| 247 | + | } | |
| 248 | + | ||
| 72 | 249 | func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |
| 73 | 250 | inR, inW, err := os.Pipe() | |
| 74 | 251 | if err != nil { | |
| @@ -103,7 +280,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||
| 103 | 280 | ||
| 104 | 281 | // Alt-screen/cursor escapes must appear exactly once for enter and exits | |
| 105 | 282 | // exactly once, regardless of how many times EnterRaw/restore were called. | |
| 106 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | |
| 283 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |
| 107 | 284 | if got := captured.String(); got != want { | |
| 108 | 285 | t.Fatalf("captured escapes = %q, want %q", got, want) | |
| 109 | 286 | } | |
| @@ -2,8 +2,10 @@ package term | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "bytes" | 4 | "bytes" | ||
| 5 | + | "errors" | |||
| 5 | "io" | 6 | "io" | ||
| 6 | "os" | 7 | "os" | ||
| 8 | + | "strings" | |||
| 7 | "testing" | 9 | "testing" | ||
| 8 | "time" | 10 | "time" | ||
| 9 | 11 | ||||
| @@ -60,7 +62,7 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||||
| 60 | _ = outW.Close() | 62 | _ = outW.Close() | ||
| 61 | <-done | 63 | <-done | ||
| 62 | 64 | ||||
| 63 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | 65 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit |
| 64 | if got := captured.String(); got != want { | 66 | if got := captured.String(); got != want { | ||
| 65 | t.Fatalf("captured escapes = %q, want %q", got, want) | 67 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 66 | } | 68 | } | ||
| @@ -69,6 +71,181 @@ func TestTerminal_EnterRaw_NonTTY_EmitsAltScreenAndCursorEscapes(t *testing.T) { | |||||
| 69 | } | 71 | } | ||
| 70 | } | 72 | } | ||
| 71 | 73 | ||||
| 74 | + | // A frame the daemon composed for a wider terminal must never bleed onto the | |||
| 75 | + | // next row: with DECAWM left on, an over-long styled run (the full-width tab | |||
| 76 | + | // bar) wraps and paints a second row in the accent colour. vev positions every | |||
| 77 | + | // row explicitly and never relies on the terminal wrapping for it, so autowrap | |||
| 78 | + | // stays off for as long as vev owns the alt screen. | |||
| 79 | + | func TestTerminal_EnterRaw_DisablesAutowrapInsideAltScreen(t *testing.T) { | |||
| 80 | + | const ( | |||
| 81 | + | autowrapOff = "\x1b[?7l" | |||
| 82 | + | autowrapOn = "\x1b[?7h" | |||
| 83 | + | altEnter = "\x1b[?1049h" | |||
| 84 | + | altExit = "\x1b[?1049l" | |||
| 85 | + | ) | |||
| 86 | + | ||||
| 87 | + | inR, inW, err := os.Pipe() | |||
| 88 | + | if err != nil { | |||
| 89 | + | t.Fatalf("os.Pipe(in): %v", err) | |||
| 90 | + | } | |||
| 91 | + | defer func() { _ = inW.Close() }() | |||
| 92 | + | defer func() { _ = inR.Close() }() | |||
| 93 | + | ||||
| 94 | + | outR, outW, captured, done := pipeCapture(t) | |||
| 95 | + | defer func() { _ = outR.Close() }() | |||
| 96 | + | ||||
| 97 | + | tm := NewWithFiles(inR, outW) | |||
| 98 | + | ||||
| 99 | + | restore, err := tm.EnterRaw() | |||
| 100 | + | if err != nil { | |||
| 101 | + | t.Fatalf("EnterRaw: %v", err) | |||
| 102 | + | } | |||
| 103 | + | if err := restore(); err != nil { | |||
| 104 | + | t.Fatalf("restore: %v", err) | |||
| 105 | + | } | |||
| 106 | + | ||||
| 107 | + | _ = outW.Close() | |||
| 108 | + | <-done | |||
| 109 | + | got := captured.String() | |||
| 110 | + | ||||
| 111 | + | for _, tc := range []struct { | |||
| 112 | + | name string | |||
| 113 | + | seq string | |||
| 114 | + | }{ | |||
| 115 | + | {"autowrap disabled exactly once", autowrapOff}, | |||
| 116 | + | {"autowrap restored exactly once", autowrapOn}, | |||
| 117 | + | } { | |||
| 118 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 119 | + | if n := strings.Count(got, tc.seq); n != 1 { | |||
| 120 | + | t.Fatalf("emitted %q %d times, want exactly 1; full output %q", tc.seq, n, got) | |||
| 121 | + | } | |||
| 122 | + | }) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | // Ordering: disabling autowrap must land inside the alt screen, and it | |||
| 126 | + | // must be restored before vev hands the screen back to the shell. | |||
| 127 | + | for _, tc := range []struct { | |||
| 128 | + | name string | |||
| 129 | + | before string | |||
| 130 | + | after string | |||
| 131 | + | }{ | |||
| 132 | + | {"alt screen entered before autowrap is disabled", altEnter, autowrapOff}, | |||
| 133 | + | {"autowrap disabled before it is restored", autowrapOff, autowrapOn}, | |||
| 134 | + | {"autowrap restored before leaving the alt screen", autowrapOn, altExit}, | |||
| 135 | + | } { | |||
| 136 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 137 | + | i, j := strings.Index(got, tc.before), strings.Index(got, tc.after) | |||
| 138 | + | if i < 0 || j < 0 { | |||
| 139 | + | t.Fatalf("missing sequence: %q at %d, %q at %d; full output %q", tc.before, i, tc.after, j, got) | |||
| 140 | + | } | |||
| 141 | + | if i >= j { | |||
| 142 | + | t.Fatalf("%q (index %d) must precede %q (index %d); full output %q", tc.before, i, tc.after, j, got) | |||
| 143 | + | } | |||
| 144 | + | }) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | // prefixThenFailWriter forwards at most prefix bytes to sink and then reports | |||
| 149 | + | // a failure, standing in for a tty that accepts part of a mode sequence and | |||
| 150 | + | // then stops. A prefix of 0 fails without emitting anything. | |||
| 151 | + | type prefixThenFailWriter struct { | |||
| 152 | + | sink io.Writer | |||
| 153 | + | prefix int | |||
| 154 | + | } | |||
| 155 | + | ||||
| 156 | + | func (w *prefixThenFailWriter) Write(p []byte) (int, error) { | |||
| 157 | + | if len(p) > w.prefix { | |||
| 158 | + | p = p[:w.prefix] | |||
| 159 | + | } | |||
| 160 | + | n := 0 | |||
| 161 | + | if len(p) > 0 { | |||
| 162 | + | var err error | |||
| 163 | + | n, err = w.sink.Write(p) | |||
| 164 | + | if err != nil { | |||
| 165 | + | return n, err | |||
| 166 | + | } | |||
| 167 | + | } | |||
| 168 | + | w.prefix -= n | |||
| 169 | + | return n, errors.New("sink failed after prefix") | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | // When the batched writer fails, part of the mode sequence may already have | |||
| 173 | + | // reached the terminal, so the visual modes must be reset straight to the fd | |||
| 174 | + | // rather than through the writer that just failed. Poisoning tm.bw while | |||
| 175 | + | // leaving tm.out intact isolates that fallback: the buffered path fails, the | |||
| 176 | + | // real descriptor still records what the fallback attempted. | |||
| 177 | + | func TestTerminal_WriteFailure_AttemptsVisualResetOnFd(t *testing.T) { | |||
| 178 | + | // Every mode EnterRaw can turn on has to be turned back off, not just the | |||
| 179 | + | // alt screen: a partial emission that got as far as cursorHide/mouseEnable | |||
| 180 | + | // would otherwise leave the cursor hidden and mouse reporting on. | |||
| 181 | + | const wantCleanup = cursorShow + cursorStyleDefault + mouseDisable + | |||
| 182 | + | bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit | |||
| 183 | + | const enterSequence = altScreenEnter + autowrapDisable + cursorHide + | |||
| 184 | + | mouseEnable + bracketedPasteEnable + colorSchemeEnable | |||
| 185 | + | ||||
| 186 | + | for _, tc := range []struct { | |||
| 187 | + | name string | |||
| 188 | + | prefix int // bytes the sink accepts before failing | |||
| 189 | + | poisonAfter bool // poison the writer after EnterRaw, i.e. fail during restore | |||
| 190 | + | want string | |||
| 191 | + | }{ | |||
| 192 | + | { | |||
| 193 | + | name: "enter fails before emitting anything", | |||
| 194 | + | want: wantCleanup, | |||
| 195 | + | }, | |||
| 196 | + | { | |||
| 197 | + | name: "enter fails after emitting part of the sequence", | |||
| 198 | + | prefix: len(altScreenEnter + autowrapDisable + cursorHide), | |||
| 199 | + | want: altScreenEnter + autowrapDisable + cursorHide + wantCleanup, | |||
| 200 | + | }, | |||
| 201 | + | { | |||
| 202 | + | name: "restore fails after a successful enter", | |||
| 203 | + | poisonAfter: true, | |||
| 204 | + | want: enterSequence + wantCleanup, | |||
| 205 | + | }, | |||
| 206 | + | } { | |||
| 207 | + | t.Run(tc.name, func(t *testing.T) { | |||
| 208 | + | inR, inW, err := os.Pipe() | |||
| 209 | + | if err != nil { | |||
| 210 | + | t.Fatalf("os.Pipe(in): %v", err) | |||
| 211 | + | } | |||
| 212 | + | defer func() { _ = inW.Close() }() | |||
| 213 | + | defer func() { _ = inR.Close() }() | |||
| 214 | + | ||||
| 215 | + | outR, outW, captured, done := pipeCapture(t) | |||
| 216 | + | defer func() { _ = outR.Close() }() | |||
| 217 | + | ||||
| 218 | + | tm := NewWithFiles(inR, outW) | |||
| 219 | + | poison := func() { | |||
| 220 | + | tm.bw = newBatchWriter(&prefixThenFailWriter{sink: outW, prefix: tc.prefix}, bufSize) | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | if !tc.poisonAfter { | |||
| 224 | + | poison() | |||
| 225 | + | if _, err := tm.EnterRaw(); err == nil { | |||
| 226 | + | t.Fatalf("EnterRaw returned nil error on a failing sink") | |||
| 227 | + | } | |||
| 228 | + | } else { | |||
| 229 | + | restore, err := tm.EnterRaw() | |||
| 230 | + | if err != nil { | |||
| 231 | + | t.Fatalf("EnterRaw: %v", err) | |||
| 232 | + | } | |||
| 233 | + | poison() | |||
| 234 | + | if err := restore(); err == nil { | |||
| 235 | + | t.Fatalf("restore returned nil error on a failing sink") | |||
| 236 | + | } | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | _ = outW.Close() | |||
| 240 | + | <-done | |||
| 241 | + | ||||
| 242 | + | if got := captured.String(); got != tc.want { | |||
| 243 | + | t.Fatalf("bytes written to the fd = %q, want %q", got, tc.want) | |||
| 244 | + | } | |||
| 245 | + | }) | |||
| 246 | + | } | |||
| 247 | + | } | |||
| 248 | + | ||||
| 72 | func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | 249 | func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | ||
| 73 | inR, inW, err := os.Pipe() | 250 | inR, inW, err := os.Pipe() | ||
| 74 | if err != nil { | 251 | if err != nil { | ||
| @@ -103,7 +280,7 @@ func TestTerminal_EnterRaw_IsIdempotentAcrossCalls(t *testing.T) { | |||||
| 103 | 280 | ||||
| 104 | // Alt-screen/cursor escapes must appear exactly once for enter and exits | 281 | // Alt-screen/cursor escapes must appear exactly once for enter and exits | ||
| 105 | // exactly once, regardless of how many times EnterRaw/restore were called. | 282 | // exactly once, regardless of how many times EnterRaw/restore were called. | ||
| 106 | - | want := altScreenEnter + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + altScreenExit | 283 | + | want := altScreenEnter + autowrapDisable + cursorHide + mouseEnable + bracketedPasteEnable + colorSchemeEnable + cursorShow + cursorStyleDefault + mouseDisable + bracketedPasteDisable + colorSchemeDisable + autowrapEnable + altScreenExit |
| 107 | if got := captured.String(); got != want { | 284 | if got := captured.String(); got != want { | ||
| 108 | t.Fatalf("captured escapes = %q, want %q", got, want) | 285 | t.Fatalf("captured escapes = %q, want %q", got, want) | ||
| 109 | } | 286 | } | ||