diff --git a/README.md b/README.md index 131cd2e..3acdee3 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,8 @@ consoles with VT processing, and browsers via xterm.js (WASM builds). Terminal (interface) └── 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 └── Backend (interface) ├── 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 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 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. diff --git a/ansi.go b/ansi.go index 35130c4..97be215 100644 --- a/ansi.go +++ b/ansi.go @@ -1,7 +1,7 @@ package terminal import ( - "bufio" + "bytes" ) // Pre-allocated ANSI sequence fragments (avoid allocations during render) @@ -49,7 +49,7 @@ var ( // writeInt writes an integer without allocation // 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 { n = 0 } @@ -79,7 +79,7 @@ func writeInt(w *bufio.Writer, n int) { } // 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) writeInt(w, y+1) w.WriteByte(';') @@ -88,7 +88,7 @@ func writeCursorPos(w *bufio.Writer, x, y int) { } // writeCursorForward writes cursor forward N positions -func writeCursorForward(w *bufio.Writer, n int) { +func writeCursorForward(w *bytes.Buffer, n int) { if n <= 0 { return } diff --git a/doc.go b/doc.go index e51caa6..50895a3 100644 --- a/doc.go +++ b/doc.go @@ -39,8 +39,11 @@ // // # Performance // -// Output uses 128KB buffered writer with cell-level diffing. Only changed cells -// generate ANSI sequences. Style attributes are coalesced to minimize SGR calls. +// Output uses cell-level diffing. Only changed cells generate ANSI sequences. +// 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. // // This package bypasses terminfo/termcap entirely, emitting direct ANSI sequences. diff --git a/output.go b/output.go index c61f61b..a4d8364 100644 --- a/output.go +++ b/output.go @@ -1,20 +1,22 @@ package terminal import ( - "bufio" + "bytes" "unicode" "unicode/utf8" "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 { front []Cell width int height int colorMode ColorMode - writer *bufio.Writer + buf bytes.Buffer cursorX int cursorY int @@ -27,7 +29,7 @@ type outputBuffer struct { lastValid bool } -// writerAdapter adapts Backend to io.Writer for bufio +// writerAdapter adapts Backend to io.Writer type writerAdapter struct { b Backend } @@ -40,13 +42,8 @@ func (wa writerAdapter) Write(p []byte) (int, error) { return len(p), nil } -func newOutputBuffer(backend Backend, colorMode ColorMode) *outputBuffer { - // Use 128KB buffer for minimal calls to backend - adapter := writerAdapter{b: backend} - return &outputBuffer{ - writer: bufio.NewWriterSize(adapter, 131072), - colorMode: colorMode, - } +func newOutputBuffer(colorMode ColorMode) *outputBuffer { + return &outputBuffer{colorMode: colorMode} } func (o *outputBuffer) resize(width, height int) { @@ -74,7 +71,7 @@ func cellEqual(a, b Cell) bool { 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) { if width != o.width || height != o.height { o.resize(width, height) @@ -85,7 +82,7 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) { return } - w := o.writer + w := &o.buf for y := 0; y < height; y++ { rowStart := y * width @@ -178,7 +175,6 @@ func (o *outputBuffer) flush(cells []Cell, width, height int) { w.Write(csiSGR0) o.lastValid = false - w.Flush() } // 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 -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 { 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 -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 { 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 // base 30 (foreground) or 40 (background): the terminal's own, a palette // 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 { return } @@ -418,10 +414,10 @@ func (o *outputBuffer) forceFullRedraw() { 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 func (o *outputBuffer) clear(bg color.RGB, attr Attr) { - w := o.writer + w := &o.buf w.Write(csiSGR0) w.Write(csi) 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.cursorValid = false - w.Flush() for i := range o.front { o.front[i] = Cell{Rune: ' ', Bg: bg, Attrs: attr & AttrBgColor} diff --git a/terminal.go b/terminal.go index 0b6d277..a1dec5b 100644 --- a/terminal.go +++ b/terminal.go @@ -5,6 +5,7 @@ import ( "os" "sync" "sync/atomic" + "time" "github.com/lixenwraith/color" "golang.org/x/term" @@ -61,10 +62,16 @@ type Terminal interface { // ColorMode returns detected color capability ColorMode() ColorMode - // Flush writes cell buffer to terminal - // Cells are row-major: cells[y*width + x] + // Flush hands a frame to the terminal; 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) + // 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(bg color.RGB) @@ -114,8 +121,28 @@ type termImpl struct { finalized bool mouseMode MouseMode 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 // DetectColorMode's, or ColorModeNone when NO_COLOR is set; an explicit mode // overrides both, as a --color=always or never would @@ -129,15 +156,19 @@ func New(colorMode ...ColorMode) Terminal { c = ColorModeNone } - t := &termImpl{ + return newTerm(b, c) +} + +func newTerm(b Backend, c ColorMode) *termImpl { + return &termImpl{ backend: b, + output: newOutputBuffer(c), syntheticCh: make(chan Event, 16), 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 @@ -178,13 +209,16 @@ func (t *termImpl) Init() error { } }) + go t.writeLoop() + // Enter alternate screen, hide cursor - t.writeRaw(csiAltScreenEnter) - t.writeRaw(csiCursorHide) + out := &t.output.buf + out.Write(csiAltScreenEnter) + out.Write(csiCursorHide) // DISABLE AUTO-WRAP // Prevents terminal scroll/wrap on bottom-right corner write - t.writeRaw(csiAutoWrapOff) + out.Write(csiAutoWrapOff) // Invisible cursor t.cursorVisible.Store(false) @@ -196,6 +230,7 @@ func (t *termImpl) Init() error { // Clear screen t.output.clear(color.RGB{}, AttrBgDefault) + t.sendLocked() // Start input reader t.input.start() @@ -207,24 +242,25 @@ func (t *termImpl) Init() error { // Fini restores terminal state func (t *termImpl) Fini() { t.mu.Lock() - defer t.mu.Unlock() - if !t.initialized || t.finalized { + t.mu.Unlock() 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 if t.mouseMode != MouseModeNone { - w := t.output.writer w.Write(csiMouseMotionOff) w.Write(csiMouseDragOff) w.Write(csiMouseClickOff) w.Write(csiMouseSGROff) - w.Flush() + t.sendLocked() } if t.paste { - t.writeRaw(csiPasteOff) + w.Write(csiPasteOff) } // Stop handlers @@ -233,21 +269,29 @@ func (t *termImpl) Fini() { } // Show cursor - t.writeRaw(csiCursorShow) + w.Write(csiCursorShow) // 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 - t.writeRaw(csiAutoWrapOn) + w.Write(csiAutoWrapOn) // 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 t.backend.Fini() - - t.finalized = true } // Size returns current terminal dimensions @@ -265,8 +309,8 @@ func (t *termImpl) ColorMode() ColorMode { return t.output.colorMode } -// Flush writes cell buffer to terminal -// Holds lock for entire operation to prevent race with Clear/MoveCursor +// Flush renders cells and queues them for the terminal, or holds them while a write +// is out. Holds lock for entire operation to prevent race with Clear/MoveCursor func (t *termImpl) Flush(cells []Cell, width, height int) { t.mu.Lock() defer t.mu.Unlock() @@ -275,6 +319,18 @@ func (t *termImpl) Flush(cells []Cell, width, height int) { 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 currW, currH := t.backend.Size() 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.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 @@ -293,7 +367,9 @@ func (t *termImpl) Clear(bg color.RGB) { return } + t.releaseLocked() t.output.clear(bg, 0) + t.sendLocked() } // SetCursorVisible shows/hides cursor @@ -309,13 +385,14 @@ func (t *termImpl) SetCursorVisible(visible bool) { return } - w := t.output.writer + t.releaseLocked() + w := &t.output.buf if visible { w.Write(csiCursorShow) } else { w.Write(csiCursorHide) } - w.Flush() + t.sendLocked() } // MoveCursor positions cursor (0-indexed) @@ -327,6 +404,7 @@ func (t *termImpl) MoveCursor(x, y int) { return } + t.releaseLocked() if t.output != nil { t.output.invalidateCursor() } @@ -345,10 +423,9 @@ func (t *termImpl) MoveCursor(x, y int) { y = h - 1 } - // Write through buffered writer to maintain stream order - wBuf := t.output.writer - writeCursorPos(wBuf, x, y) - wBuf.Flush() + // Through the queue, to keep the stream's order + writeCursorPos(&t.output.buf, x, y) + t.sendLocked() } // Sync forces full redraw @@ -362,8 +439,10 @@ func (t *termImpl) Sync() { // Clear terminal before full redraw // Diff-based rendering assumes physical terminal matches front buffer state + t.releaseLocked() t.output.clear(color.RGB{}, AttrBgDefault) t.output.forceFullRedraw() + t.sendLocked() } // PollEvent blocks until next input event @@ -412,7 +491,8 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error { oldMode := t.mouseMode t.mouseMode = mode - w := t.output.writer + t.releaseLocked() + w := &t.output.buf // Disable modes no longer needed (reverse order of enable) if oldMode&MouseModeMotion != 0 && mode&MouseModeMotion == 0 { @@ -446,7 +526,7 @@ func (t *termImpl) SetMouseMode(mode MouseMode) error { w.Write(csiMouseMotionOn) } - w.Flush() + t.sendLocked() return nil } @@ -460,18 +540,15 @@ func (t *termImpl) SetPasteMode(on bool) error { } t.paste = on t.input.pasteMode.Store(on) - w := t.output.writer + t.releaseLocked() + w := &t.output.buf if on { w.Write(csiPasteOn) } else { w.Write(csiPasteOff) } - return w.Flush() -} - -// writeRaw writes raw bytes to output -func (t *termImpl) writeRaw(data []byte) { - t.backend.Write(data) + t.sendLocked() + return nil } // EmergencyReset attempts to restore terminal to sane state diff --git a/terminal_test.go b/terminal_test.go index b8dcea8..b96a581 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -18,6 +18,7 @@ type fakeBackend struct { out bytes.Buffer chunks []string pause time.Duration + gate chan struct{} // when set, a write waits until it is closed } 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) Write(p []byte) error { + if f.gate != nil { + <-f.gate + } f.mu.Lock() defer f.mu.Unlock() 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"}, {ColorModeNone, []Cell{{Rune: 'x', Fg: red, Bg: yellow, Attrs: AttrFg256 | AttrUnderline}, {Rune: 'y', Fg: yellow, Attrs: AttrUnderline}}, "\x1b[0;4mxy"}, } { - f := &fakeBackend{} - o := newOutputBuffer(f, c.mode) + o := newOutputBuffer(c.mode) 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) } } @@ -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, // never sent to the terminal func TestCellsSendNoControlCharacters(t *testing.T) { - f := &fakeBackend{} - o := newOutputBuffer(f, ColorMode256) + o := newOutputBuffer(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) - 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) } } @@ -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 // other as itself func TestEachCellIsOneColumn(t *testing.T) { - f := &fakeBackend{} - o := newOutputBuffer(f, ColorMode256) + o := newOutputBuffer(ColorMode256) var row []Cell for _, r := range "\u0301\u20dd\u1161漢A😀✅\U0001f1fa\u093e\u0e33é─❤" { row = append(row, Cell{Rune: r}) } 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) } } @@ -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 func TestPasteModeReachesTheReader(t *testing.T) { f := &fakeBackend{} - term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)} - term.output = newOutputBuffer(f, ColorMode256) + term := newTerm(f, ColorMode256) if err := term.Init(); err != nil { t.Fatal(err) } @@ -210,8 +210,7 @@ func TestPasteModeReachesTheReader(t *testing.T) { // than painting it black func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) { f := &fakeBackend{} - term := &termImpl{backend: f, syntheticCh: make(chan Event, 1), resizeCh: make(chan ResizeEvent, 1)} - term.output = newOutputBuffer(f, ColorModeTrueColor) + term := newTerm(f, ColorModeTrueColor) if err := term.Init(); err != nil { t.Fatal(err) } @@ -228,3 +227,73 @@ func TestSessionLeavesTheTerminalAsItFoundIt(t *testing.T) { 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) + } +} diff --git a/writer.go b/writer.go new file mode 100644 index 0000000..0ccf739 --- /dev/null +++ b/writer.go @@ -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() +}