v0.3.0 write output on its own goroutine, blocked terminal report

This commit is contained in:
lixen committed 2026-10-14 20:38:40 -04:00
1 parent c657af606d
commit 0919d2a6b1
7 files changed
+288 -78

No files matched your search

+11 -1
View File
@@ -24,7 +24,8 @@ consoles with VT processing, and browsers via xterm.js (WASM builds).
Terminal (interface) Terminal (interface)
└── termImpl └── termImpl
├── outputBuffer diffing, ANSI generation, 128KB buffered writer ├── outputBuffer diffing, ANSI generation
├── writeLoop the one goroutine that writes output
├── inputReader escape sequence parser, event channel ├── inputReader escape sequence parser, event channel
└── Backend (interface) └── Backend (interface)
├── unixBackend //go:build unix — termios, unix.Poll, SIGWINCH, /dev/tty ├── unixBackend //go:build unix — termios, unix.Poll, SIGWINCH, /dev/tty
@@ -55,6 +56,15 @@ passes it to `Flush`. The output buffer diffs against the previously flushed fra
`Sync()` clears the screen and invalidates the front buffer, forcing a full `Sync()` clears the screen and invalidates the front buffer, forcing a full
redraw — required after any external process writes to the terminal. redraw — required after any external process writes to the terminal.
Output is written on a goroutine of its own, so no call waits on the terminal.
A terminal that stops reading (a hidden tab, a multiplexer client that went
away, flow control) holds that goroutine in its write; meanwhile `Flush` keeps
only the newest frame, drawn once the write returns, and control sequences
(cursor, mouse, paste, `Clear`, `Sync`) queue behind it in call order.
`Blocked()` reports how long the write in flight has waited, so a caller can
stop composing frames nobody will see. `Fini` waits up to two seconds for the
restoring sequences, then restores the termios without them.
Auto-wrap and, where the terminal follows ECMA-48 mode 8 (VTE), its own bidi Auto-wrap and, where the terminal follows ECMA-48 mode 8 (VTE), its own bidi
reordering are off during the session: the bottom-right cell is writable reordering are off during the session: the bottom-right cell is writable
without scroll side effects, and right-to-left text stays in its cells. without scroll side effects, and right-to-left text stays in its cells.
+4 -4
View File
@@ -1,7 +1,7 @@
package terminal package terminal
import ( import (
"bufio" "bytes"
) )
// Pre-allocated ANSI sequence fragments (avoid allocations during render) // Pre-allocated ANSI sequence fragments (avoid allocations during render)
@@ -49,7 +49,7 @@ var (
// writeInt writes an integer without allocation // writeInt writes an integer without allocation
// Optimized for terminal values (0-255 common, 0-999 typical max) // Optimized for terminal values (0-255 common, 0-999 typical max)
func writeInt(w *bufio.Writer, n int) { func writeInt(w *bytes.Buffer, n int) {
if n < 0 { if n < 0 {
n = 0 n = 0
} }
@@ -79,7 +79,7 @@ func writeInt(w *bufio.Writer, n int) {
} }
// writeCursorPos writes cursor positioning sequence (0-indexed input) // writeCursorPos writes cursor positioning sequence (0-indexed input)
func writeCursorPos(w *bufio.Writer, x, y int) { func writeCursorPos(w *bytes.Buffer, x, y int) {
w.Write(csiCursorPos) w.Write(csiCursorPos)
writeInt(w, y+1) writeInt(w, y+1)
w.WriteByte(';') w.WriteByte(';')
@@ -88,7 +88,7 @@ func writeCursorPos(w *bufio.Writer, x, y int) {
} }
// writeCursorForward writes cursor forward N positions // writeCursorForward writes cursor forward N positions
func writeCursorForward(w *bufio.Writer, n int) { func writeCursorForward(w *bytes.Buffer, n int) {
if n <= 0 { if n <= 0 {
return return
} }
+5 -2
View File
@@ -39,8 +39,11 @@
// //
// # Performance // # Performance
// //
// Output uses 128KB buffered writer with cell-level diffing. Only changed cells // Output uses cell-level diffing. Only changed cells generate ANSI sequences.
// generate ANSI sequences. Style attributes are coalesced to minimize SGR calls. // Style attributes are coalesced to minimize SGR calls. One goroutine writes
// what is rendered, so a terminal that stops reading never holds the caller:
// Flush keeps the newest frame until the write in flight returns, and Blocked
// reports how long it has waited.
// Input parsing is zero-allocation for common cases. // Input parsing is zero-allocation for common cases.
// //
// This package bypasses terminfo/termcap entirely, emitting direct ANSI sequences. // This package bypasses terminfo/termcap entirely, emitting direct ANSI sequences.
+15 -20
View File
@@ -1,20 +1,22 @@
package terminal package terminal
import ( import (
"bufio" "bytes"
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
"github.com/lixenwraith/color" "github.com/lixenwraith/color"
) )
// outputBuffer manages double-buffered terminal output with diffing // outputBuffer renders double-buffered terminal output with diffing. It only
// encodes: what an operation renders collects in buf, and termImpl hands it to
// the goroutine that writes it.
type outputBuffer struct { type outputBuffer struct {
front []Cell front []Cell
width int width int
height int height int
colorMode ColorMode colorMode ColorMode
writer *bufio.Writer buf bytes.Buffer
cursorX int cursorX int
cursorY int cursorY int
@@ -27,7 +29,7 @@ type outputBuffer struct {
lastValid bool lastValid bool
} }
// writerAdapter adapts Backend to io.Writer for bufio // writerAdapter adapts Backend to io.Writer
type writerAdapter struct { type writerAdapter struct {
b Backend b Backend
} }
@@ -40,13 +42,8 @@ func (wa writerAdapter) Write(p []byte) (int, error) {
return len(p), nil return len(p), nil
} }
func newOutputBuffer(backend Backend, colorMode ColorMode) *outputBuffer { func newOutputBuffer(colorMode ColorMode) *outputBuffer {
// Use 128KB buffer for minimal calls to backend return &outputBuffer{colorMode: colorMode}
adapter := writerAdapter{b: backend}
return &outputBuffer{
writer: bufio.NewWriterSize(adapter, 131072),
colorMode: colorMode,
}
} }
func (o *outputBuffer) resize(width, height int) { func (o *outputBuffer) resize(width, height int) {
@@ -74,7 +71,7 @@ func cellEqual(a, b Cell) bool {
a.Attrs == b.Attrs a.Attrs == b.Attrs
} }
// flush writes the back buffer to terminal, diffing against front buffer // flush renders the back buffer into buf, diffing against front buffer
func (o *outputBuffer) flush(cells []Cell, width, height int) { func (o *outputBuffer) flush(cells []Cell, width, height int) {
if width != o.width || height != o.height { if width != o.width || height != o.height {
o.resize(width, height) o.resize(width, height)
@@ -85,7 +82,7 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
return return
} }
w := o.writer w := &o.buf
for y := 0; y < height; y++ { for y := 0; y < height; y++ {
rowStart := y * width rowStart := y * width
@@ -178,7 +175,6 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) {
w.Write(csiSGR0) w.Write(csiSGR0)
o.lastValid = false o.lastValid = false
w.Flush()
} }
// oneColumn is r as a cell draws it: one glyph in one column, where the // oneColumn is r as a cell draws it: one glyph in one column, where the
@@ -292,7 +288,7 @@ func digitCount(n int) int {
} }
// moveCursorTo positions cursor using most efficient method // moveCursorTo positions cursor using most efficient method
func (o *outputBuffer) moveCursorTo(w *bufio.Writer, x, y int) { func (o *outputBuffer) moveCursorTo(w *bytes.Buffer, x, y int) {
if o.cursorValid && o.cursorX == x && o.cursorY == y { if o.cursorValid && o.cursorX == x && o.cursorY == y {
return return
} }
@@ -319,7 +315,7 @@ func (o *outputBuffer) moveCursorTo(w *bufio.Writer, x, y int) {
} }
// writeStyleCoalesced emits a single combined SGR sequence when style changes // writeStyleCoalesced emits a single combined SGR sequence when style changes
func (o *outputBuffer) writeStyleCoalesced(w *bufio.Writer, fg, bg color.RGB, attr Attr) { func (o *outputBuffer) writeStyleCoalesced(w *bytes.Buffer, fg, bg color.RGB, attr Attr) {
if o.colorMode == ColorModeNone { if o.colorMode == ColorModeNone {
fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle fg, bg, attr = color.RGB{}, color.RGB{}, attr&AttrStyle
} }
@@ -372,7 +368,7 @@ var attrCodes = [...]struct {
// writeColor writes one color's SGR parameters, after a ';' when sep, for // writeColor writes one color's SGR parameters, after a ';' when sep, for
// base 30 (foreground) or 40 (background): the terminal's own, a palette // base 30 (foreground) or 40 (background): the terminal's own, a palette
// index, or RGB, as the color mode allows. ColorModeNone writes nothing. // index, or RGB, as the color mode allows. ColorModeNone writes nothing.
func (o *outputBuffer) writeColor(w *bufio.Writer, c color.RGB, palette, deflt bool, base int, sep bool) { func (o *outputBuffer) writeColor(w *bytes.Buffer, c color.RGB, palette, deflt bool, base int, sep bool) {
if o.colorMode == ColorModeNone { if o.colorMode == ColorModeNone {
return return
} }
@@ -418,10 +414,10 @@ func (o *outputBuffer) forceFullRedraw() {
o.cursorValid = false o.cursorValid = false
} }
// clear writes a clear screen with specified background, attr's // clear renders a clear screen with specified background, attr's
// AttrBgDefault or AttrBg256 saying how to read it // AttrBgDefault or AttrBg256 saying how to read it
func (o *outputBuffer) clear(bg color.RGB, attr Attr) { func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
w := o.writer w := &o.buf
w.Write(csiSGR0) w.Write(csiSGR0)
w.Write(csi) w.Write(csi)
o.writeColor(w, bg, attr&AttrBg256 != 0, attr&AttrBgDefault != 0, 40, false) o.writeColor(w, bg, attr&AttrBg256 != 0, attr&AttrBgDefault != 0, 40, false)
@@ -430,7 +426,6 @@ func (o *outputBuffer) clear(bg color.RGB, attr Attr) {
o.lastValid = false o.lastValid = false
o.cursorValid = false o.cursorValid = false
w.Flush()
for i := range o.front { for i := range o.front {
o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor} o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor}
+115 -38
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time"
"github.com/lixenwraith/color" "github.com/lixenwraith/color"
"golang.org/x/term" "golang.org/x/term"
@@ -61,10 +62,16 @@ type Terminal interface {
// ColorMode returns detected color capability // ColorMode returns detected color capability
ColorMode() ColorMode ColorMode() ColorMode
// Flush writes cell buffer to terminal // Flush hands a frame to the terminal; cells are row-major: cells[y*width + x].
// Cells are row-major: cells[y*width + x] // It never waits on the terminal: while the last write is still out, the frame
// is held and drawn once that write returns, a newer one replacing it
Flush(cells []Cell, width, height int) Flush(cells []Cell, width, height int)
// Blocked reports how long the terminal has held the write in flight, zero
// while output drains. A terminal that stops reading (a hidden tab, a client
// that went away, flow control) shows here instead of as a caller that waits
Blocked() time.Duration
// Clear fills screen with specified background color // Clear fills screen with specified background color
Clear(bg color.RGB) Clear(bg color.RGB)
@@ -114,8 +121,28 @@ type termImpl struct {
finalized bool finalized bool
mouseMode MouseMode mouseMode MouseMode
paste bool paste bool
// Output reaches the terminal on writeLoop's goroutine, so a terminal that
// stops reading holds that goroutine rather than whoever draws. queued is what
// was rendered and not yet taken; a frame Flush is handed while a write is out
// waits in held, newest first, until it returns. Both are guarded by mu.
queued []byte
writing []byte // owned by writeLoop
held []Cell
heldW int
heldH int
holding bool
writeSince atomic.Int64 // unix nanoseconds the write out began; zero when none is
wake chan struct{}
quit chan struct{}
writerDone chan struct{}
} }
// finiDrainTimeout bounds how long Fini waits for the terminal to take what is
// queued, the restoring sequences last: a terminal that stopped reading would
// otherwise hold the process at exit
const finiDrainTimeout = 2 * time.Second
// New creates a new Terminal instance. Without a mode it uses // New creates a new Terminal instance. Without a mode it uses
// DetectColorMode's, or ColorModeNone when NO_COLOR is set; an explicit mode // DetectColorMode's, or ColorModeNone when NO_COLOR is set; an explicit mode
// overrides both, as a --color=always or never would // overrides both, as a --color=always or never would
@@ -129,15 +156,19 @@ func New(colorMode ...ColorMode) Terminal {
c = ColorModeNone c = ColorModeNone
} }
t := &termImpl{ return newTerm(b, c)
}
func newTerm(b Backend, c ColorMode) *termImpl {
return &termImpl{
backend: b, backend: b,
output: newOutputBuffer(c),
syntheticCh: make(chan Event, 16), syntheticCh: make(chan Event, 16),
resizeCh: make(chan ResizeEvent, 1), resizeCh: make(chan ResizeEvent, 1),
wake: make(chan struct{}, 1),
quit: make(chan struct{}),
writerDone: make(chan struct{}),
} }
// Initialize output buffer with backend
t.output = newOutputBuffer(b, c)
return t
} }
// Init enters raw mode and sets up terminal // Init enters raw mode and sets up terminal
@@ -178,13 +209,16 @@ func (t *termImpl) Init() error {
} }
}) })
go t.writeLoop()
// Enter alternate screen, hide cursor // Enter alternate screen, hide cursor
t.writeRaw(csiAltScreenEnter) out := &t.output.buf
t.writeRaw(csiCursorHide) out.Write(csiAltScreenEnter)
out.Write(csiCursorHide)
// DISABLE AUTO-WRAP // DISABLE AUTO-WRAP
// Prevents terminal scroll/wrap on bottom-right corner write // Prevents terminal scroll/wrap on bottom-right corner write
t.writeRaw(csiAutoWrapOff) out.Write(csiAutoWrapOff)
// Invisible cursor // Invisible cursor
t.cursorVisible.Store(false) t.cursorVisible.Store(false)
@@ -196,6 +230,7 @@ func (t *termImpl) Init() error {
// Clear screen // Clear screen
t.output.clear(color.RGB{}, AttrBgDefault) t.output.clear(color.RGB{}, AttrBgDefault)
t.sendLocked()
// Start input reader // Start input reader
t.input.start() t.input.start()
@@ -207,24 +242,25 @@ func (t *termImpl) Init() error {
// Fini restores terminal state // Fini restores terminal state
func (t *termImpl) Fini() { func (t *termImpl) Fini() {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock()
if !t.initialized || t.finalized { if !t.initialized || t.finalized {
t.mu.Unlock()
return return
} }
t.finalized = true
t.holding = false // a frame not yet drawn is not drawn over the restored screen
w := &t.output.buf
// Disable mouse before other cleanup // Disable mouse before other cleanup
if t.mouseMode != MouseModeNone { if t.mouseMode != MouseModeNone {
w := t.output.writer
w.Write(csiMouseMotionOff) w.Write(csiMouseMotionOff)
w.Write(csiMouseDragOff) w.Write(csiMouseDragOff)
w.Write(csiMouseClickOff) w.Write(csiMouseClickOff)
w.Write(csiMouseSGROff) w.Write(csiMouseSGROff)
w.Flush() t.sendLocked()
} }
if t.paste { if t.paste {
t.writeRaw(csiPasteOff) w.Write(csiPasteOff)
} }
// Stop handlers // Stop handlers
@@ -233,21 +269,29 @@ func (t *termImpl) Fini() {
} }
// Show cursor // Show cursor
t.writeRaw(csiCursorShow) w.Write(csiCursorShow)
// Exit alternate screen // Exit alternate screen
t.writeRaw(csiAltScreenExit) w.Write(csiAltScreenExit)
// Re-enable Auto-Wrap AFTER exiting alt screen to ensure the main buffer has wrap enabled // Re-enable Auto-Wrap AFTER exiting alt screen to ensure the main buffer has wrap enabled
t.writeRaw(csiAutoWrapOn) w.Write(csiAutoWrapOn)
// Reset attributes // Reset attributes
t.writeRaw(csiSGR0) w.Write(csiSGR0)
t.sendLocked()
t.mu.Unlock()
// The restoring sequences leave before raw mode does, unless the terminal has
// stopped reading: then the termios is restored without them
close(t.quit)
select {
case <-t.writerDone:
case <-time.After(finiDrainTimeout):
}
// Backend cleanup // Backend cleanup
t.backend.Fini() t.backend.Fini()
t.finalized = true
} }
// Size returns current terminal dimensions // Size returns current terminal dimensions
@@ -265,8 +309,8 @@ func (t *termImpl) ColorMode() ColorMode {
return t.output.colorMode return t.output.colorMode
} }
// Flush writes cell buffer to terminal // Flush renders cells and queues them for the terminal, or holds them while a write
// Holds lock for entire operation to prevent race with Clear/MoveCursor // is out. Holds lock for entire operation to prevent race with Clear/MoveCursor
func (t *termImpl) Flush(cells []Cell, width, height int) { func (t *termImpl) Flush(cells []Cell, width, height int) {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
@@ -275,6 +319,18 @@ func (t *termImpl) Flush(cells []Cell, width, height int) {
return return
} }
if t.writeSince.Load() != 0 {
t.held = append(t.held[:0], cells[:min(len(cells), width*height)]...)
t.heldW, t.heldH, t.holding = width, height, true
return
}
// A frame is a whole screen, so one rendered now supersedes any held
t.holding = false
t.renderLocked(cells, width, height)
}
// renderLocked diffs one frame into the queue. Caller MUST hold mu.
func (t *termImpl) renderLocked(cells []Cell, width, height int) {
// Validation against backend size; if mismatch, drop frame to prevent resize race corruption // Validation against backend size; if mismatch, drop frame to prevent resize race corruption
currW, currH := t.backend.Size() currW, currH := t.backend.Size()
if currW != width || currH != height { if currW != width || currH != height {
@@ -282,6 +338,24 @@ func (t *termImpl) Flush(cells []Cell, width, height int) {
} }
t.output.flush(cells, width, height) t.output.flush(cells, width, height)
t.sendLocked()
}
// releaseLocked renders a held frame ahead of a control sequence, so the terminal
// receives the two in the order they were asked for. Caller MUST hold mu.
func (t *termImpl) releaseLocked() {
if t.holding {
t.holding = false
t.renderLocked(t.held, t.heldW, t.heldH)
}
}
// Blocked reports how long the write in flight has waited on the terminal
func (t *termImpl) Blocked() time.Duration {
if since := t.writeSince.Load(); since != 0 {
return time.Since(time.Unix(0, since))
}
return 0
} }
// Clear fills screen with background color // Clear fills screen with background color
@@ -293,7 +367,9 @@ func (t *termImpl) Clear(bg color.RGB) {
return return
} }
t.releaseLocked()
t.output.clear(bg, 0) t.output.clear(bg, 0)
t.sendLocked()
} }
// SetCursorVisible shows/hides cursor // SetCursorVisible shows/hides cursor
@@ -309,13 +385,14 @@ func (t *termImpl) SetCursorVisible(visible bool) {
return return
} }
w := t.output.writer t.releaseLocked()
w := &t.output.buf
if visible { if visible {
w.Write(csiCursorShow) w.Write(csiCursorShow)
} else { } else {
w.Write(csiCursorHide) w.Write(csiCursorHide)
} }
w.Flush() t.sendLocked()
} }
// MoveCursor positions cursor (0-indexed) // MoveCursor positions cursor (0-indexed)
@@ -327,6 +404,7 @@ func (t *termImpl) MoveCursor(x, y int) {
return return
} }
t.releaseLocked()
if t.output != nil { if t.output != nil {
t.output.invalidateCursor() t.output.invalidateCursor()
} }
@@ -345,10 +423,9 @@ func (t *termImpl) MoveCursor(x, y int) {
y = h - 1 y = h - 1
} }
// Write through buffered writer to maintain stream order // Through the queue, to keep the stream's order
wBuf := t.output.writer writeCursorPos(&t.output.buf, x, y)
writeCursorPos(wBuf, x, y) t.sendLocked()
wBuf.Flush()
} }
// Sync forces full redraw // Sync forces full redraw
@@ -362,8 +439,10 @@ func (t *termImpl) Sync() {
// Clear terminal before full redraw // Clear terminal before full redraw
// Diff-based rendering assumes physical terminal matches front buffer state // Diff-based rendering assumes physical terminal matches front buffer state
t.releaseLocked()
t.output.clear(color.RGB{}, AttrBgDefault) t.output.clear(color.RGB{}, AttrBgDefault)
t.output.forceFullRedraw() t.output.forceFullRedraw()
t.sendLocked()
} }
// PollEvent blocks until next input event // PollEvent blocks until next input event
@@ -412,7 +491,8 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
oldMode := t.mouseMode oldMode := t.mouseMode
t.mouseMode = mode t.mouseMode = mode
w := t.output.writer t.releaseLocked()
w := &t.output.buf
// Disable modes no longer needed (reverse order of enable) // Disable modes no longer needed (reverse order of enable)
if oldMode&MouseModeMotion != 0 && mode&MouseModeMotion == 0 { if oldMode&MouseModeMotion != 0 && mode&MouseModeMotion == 0 {
@@ -446,7 +526,7 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error {
w.Write(csiMouseMotionOn) w.Write(csiMouseMotionOn)
} }
w.Flush() t.sendLocked()
return nil return nil
} }
@@ -460,18 +540,15 @@ func (t *termImpl) SetPasteMode(on bool) error {
} }
t.paste = on t.paste = on
t.input.pasteMode.Store(on) t.input.pasteMode.Store(on)
w := t.output.writer t.releaseLocked()
w := &t.output.buf
if on { if on {
w.Write(csiPasteOn) w.Write(csiPasteOn)
} else { } else {
w.Write(csiPasteOff) w.Write(csiPasteOff)
} }
return w.Flush() t.sendLocked()
} return nil
// writeRaw writes raw bytes to output
func (t *termImpl) writeRaw(data []byte) {
t.backend.Write(data)
} }
// EmergencyReset attempts to restore terminal to sane state // EmergencyReset attempts to restore terminal to sane state
+82 -13
View File
@@ -18,6 +18,7 @@ type fakeBackend struct {
out bytes.Buffer out bytes.Buffer
chunks []string chunks []string
pause time.Duration pause time.Duration
gate chan struct{} // when set, a write waits until it is closed
} }
func (f *fakeBackend) Init() error { return nil } func (f *fakeBackend) Init() error { return nil }
@@ -26,6 +27,9 @@ func (f *fakeBackend) Size() (int, int) { return 1, 1 }
func (f *fakeBackend) SetResizeHandler(func(width, height int)) {} func (f *fakeBackend) SetResizeHandler(func(width, height int)) {}
func (f *fakeBackend) Write(p []byte) error { func (f *fakeBackend) Write(p []byte) error {
if f.gate != nil {
<-f.gate
}
f.mu.Lock() f.mu.Lock()
defer f.mu.Unlock() defer f.mu.Unlock()
f.out.Write(p) f.out.Write(p)
@@ -80,10 +84,9 @@ func TestColorModesWriteWhatTheTerminalReads(t *testing.T) {
{ColorModeTrueColor, []Cell{{Rune: 'x', Fg: color.RGB{R: 1, G: 2, B: 3}, Attrs: AttrBgDefault}}, "\x1b[0;38;2;1;2;3;49mx"}, {ColorModeTrueColor, []Cell{{Rune: 'x', Fg: color.RGB{R: 1, G: 2, B: 3}, Attrs: AttrBgDefault}}, "\x1b[0;38;2;1;2;3;49mx"},
{ColorModeNone, []Cell{{Rune: 'x', Fg: red, Bg: yellow, Attrs: AttrFg256 | AttrUnderline}, {Rune: 'y', Fg: yellow, Attrs: AttrUnderline}}, "\x1b[0;4mxy"}, {ColorModeNone, []Cell{{Rune: 'x', Fg: red, Bg: yellow, Attrs: AttrFg256 | AttrUnderline}, {Rune: 'y', Fg: yellow, Attrs: AttrUnderline}}, "\x1b[0;4mxy"},
} { } {
f := &fakeBackend{} o := newOutputBuffer(c.mode)
o := newOutputBuffer(f, c.mode)
o.flush(c.cells, len(c.cells), 1) o.flush(c.cells, len(c.cells), 1)
if got, want := f.written(), "\x1b[1;1H"+c.want+"\x1b[0m"; got != want { if got, want := o.buf.String(), "\x1b[1;1H"+c.want+"\x1b[0m"; got != want {
t.Errorf("mode %d %+v: %q, want %q", c.mode, c.cells, got, want) t.Errorf("mode %d %+v: %q, want %q", c.mode, c.cells, got, want)
} }
} }
@@ -94,10 +97,9 @@ func TestColorModesWriteWhatTheTerminalReads(t *testing.T) {
// value or a log line carried in, or no rune at all, is drawn as a space, // value or a log line carried in, or no rune at all, is drawn as a space,
// never sent to the terminal // never sent to the terminal
func TestCellsSendNoControlCharacters(t *testing.T) { func TestCellsSendNoControlCharacters(t *testing.T) {
f := &fakeBackend{} o := newOutputBuffer(ColorMode256)
o := newOutputBuffer(f, ColorMode256)
o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 0xd800}, {Rune: 0x202e}, {Rune: 0x2067}, {Rune: 0x061c}, {Rune: 0x200b}, {Rune: 0x2028}, {Rune: 'é'}}, 11, 1) o.flush([]Cell{{Rune: 0x1b}, {Rune: 0x9b}, {Rune: 0x7f}, {Rune: -229}, {Rune: 0xd800}, {Rune: 0x202e}, {Rune: 0x2067}, {Rune: 0x061c}, {Rune: 0x200b}, {Rune: 0x2028}, {Rune: 'é'}}, 11, 1)
if got, want := f.written(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want { if got, want := o.buf.String(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m é\x1b[0m"; got != want {
t.Fatalf("%q, want %q", got, want) t.Fatalf("%q, want %q", got, want)
} }
} }
@@ -107,14 +109,13 @@ func TestCellsSendNoControlCharacters(t *testing.T) {
// (a spacing mark, a letter such as Thai's sara am) is drawn as U+FFFD, any // (a spacing mark, a letter such as Thai's sara am) is drawn as U+FFFD, any
// other as itself // other as itself
func TestEachCellIsOneColumn(t *testing.T) { func TestEachCellIsOneColumn(t *testing.T) {
f := &fakeBackend{} o := newOutputBuffer(ColorMode256)
o := newOutputBuffer(f, ColorMode256)
var row []Cell var row []Cell
for _, r := range "\u0301\u20dd\u1161漢A😀✅\U0001f1fa\u093e\u0e33é─❤" { for _, r := range "\u0301\u20dd\u1161漢A😀✅\U0001f1fa\u093e\u0e33é─❤" {
row = append(row, Cell{Rune: r}) row = append(row, Cell{Rune: r})
} }
o.flush(row, len(row), 1) o.flush(row, len(row), 1)
if got, want := f.written(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m"+strings.Repeat("\ufffd", 10)+"é─❤\x1b[0m"; got != want { if got, want := o.buf.String(), "\x1b[1;1H\x1b[0;38;5;16;48;5;16m"+strings.Repeat("\ufffd", 10)+"é─❤\x1b[0m"; got != want {
t.Fatalf("%q, want %q", got, want) t.Fatalf("%q, want %q", got, want)
} }
} }
@@ -183,8 +184,7 @@ func expectEvents(t *testing.T, cases map[string]input) {
// Paste mode set on the terminal is what its reader parses by // Paste mode set on the terminal is what its reader parses by
func TestPasteModeReachesTheReader(t *testing.T) { func TestPasteModeReachesTheReader(t *testing.T) {
f := &fakeBackend{} f := &fakeBackend{}
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)} term := newTerm(f, ColorMode256)
term.output = newOutputBuffer(f, ColorMode256)
if err := term.Init(); err != nil { if err := term.Init(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -210,8 +210,7 @@ func TestPasteModeReachesTheReader(t *testing.T) {
// than painting it black // than painting it black
func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) { func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
f := &fakeBackend{} f := &fakeBackend{}
term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)} term := newTerm(f, ColorModeTrueColor)
term.output = newOutputBuffer(f, ColorModeTrueColor)
if err := term.Init(); err != nil { if err := term.Init(); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -228,3 +227,73 @@ func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) {
t.Fatalf("bidi reordering not off for the session: %q", got) t.Fatalf("bidi reordering not off for the session: %q", got)
} }
} }
// A terminal that stops reading holds the write in flight, not whoever draws:
// Flush returns while it is out, Blocked says how long it has waited, the newest
// frame held meanwhile is the one drawn once the terminal reads again, and a
// control sequence asked for after a held frame reaches the terminal after it
func TestAStoppedTerminalHoldsNoCaller(t *testing.T) {
f := &fakeBackend{gate: make(chan struct{})}
term := newTerm(f, ColorMode256)
if err := term.Init(); err != nil {
t.Fatal(err)
}
waitFor(t, "the first write to go out", func() bool { return term.Blocked() > 0 })
frame := func(r rune) []Cell { return []Cell{{Rune: r}} }
returned := make(chan struct{})
go func() {
term.Flush(frame('a'), 1, 1)
term.Flush(frame('b'), 1, 1)
close(returned)
}()
select {
case <-returned:
case <-time.After(2 * time.Second):
t.Fatal("Flush waited on a terminal that stopped reading")
}
time.Sleep(10 * time.Millisecond)
if b := term.Blocked(); b < 10*time.Millisecond {
t.Fatalf("blocked %v while the write is held", b)
}
term.SetCursorVisible(true)
close(f.gate)
waitFor(t, "output to drain", func() bool { return term.Blocked() == 0 && strings.Contains(f.written(), "\x1b[?25h") })
got := f.written()
if strings.Contains(got, "a") || !strings.Contains(got, "b") {
t.Fatalf("drew %q; want the newest held frame only", got)
}
if strings.Index(got, "b") > strings.Index(got, "\x1b[?25h") {
t.Fatalf("the cursor was shown before the frame held ahead of it: %q", got)
}
term.Fini()
}
// Fini restores the termios even when the terminal never reads its last
// sequences: it stops waiting after finiDrainTimeout
func TestFiniLeavesAStoppedTerminal(t *testing.T) {
f := &fakeBackend{gate: make(chan struct{})}
defer close(f.gate)
term := newTerm(f, ColorMode256)
if err := term.Init(); err != nil {
t.Fatal(err)
}
done := make(chan struct{})
go func() { term.Fini(); close(done) }()
select {
case <-done:
case <-time.After(finiDrainTimeout + 2*time.Second):
t.Fatal("Fini waited on a terminal that stopped reading")
}
}
func waitFor(t *testing.T, what string, ok func() bool) {
t.Helper()
for deadline := time.Now().Add(2 * time.Second); !ok(); {
if time.Now().After(deadline) {
t.Fatalf("timed out waiting for %s", what)
}
time.Sleep(time.Millisecond)
}
}
+56
View File
@@ -0,0 +1,56 @@
package terminal
import "time"
// sendLocked queues what the operation in progress rendered and wakes the writer.
// Caller MUST hold mu.
func (t *termImpl) sendLocked() {
buf := &t.output.buf
if buf.Len() == 0 {
return
}
t.queued = append(t.queued, buf.Bytes()...)
buf.Reset()
select {
case t.wake <- struct{}{}:
default:
}
}
// writeLoop is the only writer of terminal output after Init. It returns once Fini
// has queued the restoring sequences and they are written; a terminal that never
// takes them leaves it in its write, and Fini stops waiting.
func (t *termImpl) writeLoop() {
defer close(t.writerDone)
for {
select {
case <-t.wake:
t.drain()
case <-t.quit:
t.drain()
return
}
}
}
// drain writes what is queued until nothing is, and draws a frame held while a
// write was out once that write returns. The write runs without mu, so whoever
// draws meanwhile finds writeSince set and holds its frame instead of waiting.
func (t *termImpl) drain() {
t.mu.Lock()
for {
if len(t.queued) == 0 {
t.releaseLocked()
}
if len(t.queued) == 0 {
break
}
t.writing, t.queued = t.queued, t.writing[:0]
t.writeSince.Store(time.Now().UnixNano())
t.mu.Unlock()
_ = t.backend.Write(t.writing) // a failed write is lost, as one through a buffered writer was
t.writeSince.Store(0)
t.mu.Lock()
}
t.mu.Unlock()
}