mirror of
https://github.com/VictoriaMetrics/VictoriaMetrics.git
synced 2024-11-21 14:44:00 +00:00
490 lines
13 KiB
Go
490 lines
13 KiB
Go
package readline
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/ergochat/readline/internal/ansi"
|
|
"github.com/ergochat/readline/internal/platform"
|
|
)
|
|
|
|
const (
|
|
// see waitForDSR
|
|
dsrTimeout = 250 * time.Millisecond
|
|
|
|
maxAnsiLen = 32
|
|
|
|
// how many non-CPR reads to buffer while waiting for a CPR response
|
|
maxCPRBufferLen = 128 * 1024
|
|
)
|
|
|
|
var (
|
|
deadlineExceeded = errors.New("deadline exceeded")
|
|
concurrentReads = errors.New("concurrent read operations detected")
|
|
invalidCPR = errors.New("invalid CPR response")
|
|
)
|
|
|
|
/*
|
|
terminal manages terminal input. The design constraints here are somewhat complex:
|
|
|
|
1. Calls to (*Instance).Readline() must always be preemptible by (*Instance).Close.
|
|
This could be handled at the Operation layer instead; however, it's cleaner
|
|
to provide an API in terminal itself that can interrupt attempts to read.
|
|
2. In between calls to Readline(), or *after* a call to (*Instance).Close(),
|
|
stdin must be available for code outside of this library to read from. The
|
|
problem is that reads from stdin in Go are not preemptible (see, for example,
|
|
https://github.com/golang/go/issues/24842 ). In the worst case, an
|
|
interrupted read will leave (*terminal).ioloop() running, and it will
|
|
consume one more user keystroke before it exits. However, it is a design goal
|
|
to read as little as possible at a time.
|
|
3. We have to handle the DSR ("device status report") query and the
|
|
CPR ("cursor position report") response:
|
|
https://vt100.net/docs/vt510-rm/DSR-CPR.html
|
|
This involves writing an ANSI escape sequence to stdout, then waiting
|
|
for the terminal to asynchronously write an ANSI escape sequence to stdin.
|
|
We have to pick this value out of the stream and process it without
|
|
disrupting the handling of actual user input. Moreover, concurrent Close()
|
|
while a CPR query is in flight should ensure (if possible) that the
|
|
response is actually read; otherwise the response may be printed to the
|
|
screen, disrupting the user experience.
|
|
|
|
Accordingly, the concurrency design is as follows:
|
|
|
|
1. ioloop() runs asynchronously. It operates in lockstep with the read methods:
|
|
each synchronous receive from kickChan is matched with a synchronous send to
|
|
outChan. It does blocking reads from stdin, reading as little as possible at
|
|
a time, and passing the results back over outChan.
|
|
2. The read methods ("internal public API") GetRune() and GetCursorPosition()
|
|
are not concurrency-safe and must be called in serial. They are backed by
|
|
readFromStdin, which wakes ioloop() if necessary and waits for a response.
|
|
If GetCursorPosition() reads non-CPR data, it will buffer it for GetRune()
|
|
to read later.
|
|
3. Close() can be called asynchronously. It interrupts ioloop() (unless ioloop()
|
|
is actually reading from stdin, in which case it interrupts it after the next
|
|
keystroke), and also interrupts any in-progress GetRune() call. If
|
|
GetCursorPosition() is in progress, it tries to wait until the CPR response
|
|
has been received. It is idempotent and can be called multiple times.
|
|
*/
|
|
|
|
type terminal struct {
|
|
cfg atomic.Pointer[Config]
|
|
dimensions atomic.Pointer[termDimensions]
|
|
closeOnce sync.Once
|
|
closeErr error
|
|
outChan chan readResult
|
|
kickChan chan struct{}
|
|
stopChan chan struct{}
|
|
buffer []rune // actual input that we saw while waiting for the CPR
|
|
inFlight bool // tracks whether we initiated a read and then gave up waiting
|
|
sleeping int32
|
|
|
|
// asynchronously receive DSR messages from the terminal,
|
|
// ensuring at most one query is in flight at a time
|
|
dsrLock sync.Mutex
|
|
dsrDone chan struct{} // nil if there is no DSR query in flight
|
|
}
|
|
|
|
// termDimensions stores the terminal width and height (-1 means unknown)
|
|
type termDimensions struct {
|
|
width int
|
|
height int
|
|
}
|
|
|
|
type cursorPosition struct {
|
|
row int
|
|
col int
|
|
}
|
|
|
|
// readResult represents the result of a single "read operation" from the
|
|
// perspective of terminal. it may be a pure no-op. the consumer needs to
|
|
// read again if it didn't get what it wanted
|
|
type readResult struct {
|
|
r rune
|
|
ok bool // is `r` valid user input? if not, we may need to read again
|
|
// other data that can be conveyed in a single read operation;
|
|
// currently only the CPR:
|
|
pos *cursorPosition
|
|
}
|
|
|
|
func newTerminal(cfg *Config) (*terminal, error) {
|
|
if cfg.isInteractive {
|
|
if ansiErr := ansi.EnableANSI(); ansiErr != nil {
|
|
return nil, fmt.Errorf("Could not enable ANSI escapes: %w", ansiErr)
|
|
}
|
|
}
|
|
t := &terminal{
|
|
kickChan: make(chan struct{}),
|
|
outChan: make(chan readResult),
|
|
stopChan: make(chan struct{}),
|
|
}
|
|
t.SetConfig(cfg)
|
|
// Get and cache the current terminal size.
|
|
t.OnSizeChange()
|
|
|
|
go t.ioloop()
|
|
return t, nil
|
|
}
|
|
|
|
// SleepToResume will sleep myself, and return only if I'm resumed.
|
|
func (t *terminal) SleepToResume() {
|
|
if !atomic.CompareAndSwapInt32(&t.sleeping, 0, 1) {
|
|
return
|
|
}
|
|
defer atomic.StoreInt32(&t.sleeping, 0)
|
|
|
|
t.ExitRawMode()
|
|
platform.SuspendProcess()
|
|
t.EnterRawMode()
|
|
}
|
|
|
|
func (t *terminal) EnterRawMode() (err error) {
|
|
return t.GetConfig().FuncMakeRaw()
|
|
}
|
|
|
|
func (t *terminal) ExitRawMode() (err error) {
|
|
return t.GetConfig().FuncExitRaw()
|
|
}
|
|
|
|
func (t *terminal) Write(b []byte) (int, error) {
|
|
return t.GetConfig().Stdout.Write(b)
|
|
}
|
|
|
|
// getOffset sends a DSR query to get the current offset, then blocks
|
|
// until the query returns.
|
|
func (t *terminal) GetCursorPosition(deadline chan struct{}) (cursorPosition, error) {
|
|
// ensure there is no in-flight query, set up a waiter
|
|
ok := func() (ok bool) {
|
|
t.dsrLock.Lock()
|
|
defer t.dsrLock.Unlock()
|
|
if t.dsrDone == nil {
|
|
t.dsrDone = make(chan struct{})
|
|
ok = true
|
|
}
|
|
return
|
|
}()
|
|
|
|
if !ok {
|
|
return cursorPosition{-1, -1}, concurrentReads
|
|
}
|
|
|
|
defer func() {
|
|
t.dsrLock.Lock()
|
|
defer t.dsrLock.Unlock()
|
|
close(t.dsrDone)
|
|
t.dsrDone = nil
|
|
}()
|
|
|
|
// send the DSR Cursor Position Report request to terminal stdout:
|
|
// https://vt100.net/docs/vt510-rm/DSR-CPR.html
|
|
_, err := t.Write([]byte("\x1b[6n"))
|
|
if err != nil {
|
|
return cursorPosition{-1, -1}, err
|
|
}
|
|
|
|
for {
|
|
result, err := t.readFromStdin(deadline)
|
|
if err != nil {
|
|
return cursorPosition{-1, -1}, err
|
|
}
|
|
if result.ok {
|
|
// non-CPR input, save it to be read later:
|
|
t.buffer = append(t.buffer, result.r)
|
|
if len(t.buffer) > maxCPRBufferLen {
|
|
panic("did not receive DSR CPR response")
|
|
}
|
|
}
|
|
if result.pos != nil {
|
|
return *result.pos, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// waitForDSR waits for any in-flight DSR query to complete. this prevents
|
|
// garbage from being written to the terminal when Close() interrupts an
|
|
// in-flight query.
|
|
func (t *terminal) waitForDSR() {
|
|
t.dsrLock.Lock()
|
|
dsrDone := t.dsrDone
|
|
t.dsrLock.Unlock()
|
|
if dsrDone != nil {
|
|
// tradeoffs: if the timeout is too high, we risk slowing down Close();
|
|
// if it's too low, we risk writing the CPR to the terminal, which is bad UX,
|
|
// but neither of these outcomes is catastrophic
|
|
timer := time.NewTimer(dsrTimeout)
|
|
select {
|
|
case <-dsrDone:
|
|
case <-timer.C:
|
|
}
|
|
timer.Stop()
|
|
}
|
|
}
|
|
|
|
func (t *terminal) GetRune(deadline chan struct{}) (rune, error) {
|
|
if len(t.buffer) > 0 {
|
|
result := t.buffer[0]
|
|
t.buffer = t.buffer[1:]
|
|
return result, nil
|
|
}
|
|
return t.getRuneFromStdin(deadline)
|
|
}
|
|
|
|
func (t *terminal) getRuneFromStdin(deadline chan struct{}) (rune, error) {
|
|
for {
|
|
result, err := t.readFromStdin(deadline)
|
|
if err != nil {
|
|
return 0, err
|
|
} else if result.ok {
|
|
return result.r, nil
|
|
} // else: CPR or something else we didn't understand, read again
|
|
}
|
|
}
|
|
|
|
func (t *terminal) readFromStdin(deadline chan struct{}) (result readResult, err error) {
|
|
// we may have sent a kick previously and given up on the response;
|
|
// if so, don't kick again (we will try again to read the pending response)
|
|
if !t.inFlight {
|
|
select {
|
|
case t.kickChan <- struct{}{}:
|
|
t.inFlight = true
|
|
case <-t.stopChan:
|
|
return result, io.EOF
|
|
case <-deadline:
|
|
return result, deadlineExceeded
|
|
}
|
|
}
|
|
|
|
select {
|
|
case result = <-t.outChan:
|
|
t.inFlight = false
|
|
return result, nil
|
|
case <-t.stopChan:
|
|
return result, io.EOF
|
|
case <-deadline:
|
|
return result, deadlineExceeded
|
|
}
|
|
}
|
|
|
|
func (t *terminal) ioloop() {
|
|
// ensure close if we get an error from stdio
|
|
defer t.Close()
|
|
|
|
buf := bufio.NewReader(t.GetConfig().Stdin)
|
|
var ansiBuf bytes.Buffer
|
|
|
|
for {
|
|
select {
|
|
case <-t.kickChan:
|
|
case <-t.stopChan:
|
|
return
|
|
}
|
|
|
|
r, _, err := buf.ReadRune()
|
|
if err != nil {
|
|
return
|
|
}
|
|
|
|
var result readResult
|
|
if r == '\x1b' {
|
|
// we're starting an ANSI escape sequence:
|
|
// keep reading until we reach the end of the sequence
|
|
result, err = t.consumeANSIEscape(buf, &ansiBuf)
|
|
if err != nil {
|
|
return
|
|
}
|
|
} else {
|
|
result = readResult{r: r, ok: true}
|
|
}
|
|
|
|
select {
|
|
case t.outChan <- result:
|
|
case <-t.stopChan:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (t *terminal) consumeANSIEscape(buf *bufio.Reader, ansiBuf *bytes.Buffer) (result readResult, err error) {
|
|
ansiBuf.Reset()
|
|
initial, _, err := buf.ReadRune()
|
|
if err != nil {
|
|
return
|
|
}
|
|
// we already read one \x1b. this can indicate either the start of an ANSI
|
|
// escape sequence, or a keychord with Alt (e.g. Alt+f produces `\x1bf` in
|
|
// a typical xterm).
|
|
switch initial {
|
|
case 'f':
|
|
// Alt-f in xterm, or Option+RightArrow in iTerm2 with "Natural text editing"
|
|
return readResult{r: MetaForward, ok: true}, nil // Alt-f
|
|
case 'b':
|
|
// Alt-b in xterm, or Option+LeftArrow in iTerm2 with "Natural text editing"
|
|
return readResult{r: MetaBackward, ok: true}, nil // Alt-b
|
|
case '[', 'O':
|
|
// this is a real ANSI escape sequence, read the rest of the sequence below:
|
|
case '\x1b':
|
|
// Alt plus a real ANSI escape sequence. Handle this specially since
|
|
// right now the only cases we want to handle are the arrow keys:
|
|
return consumeAltSequence(buf)
|
|
default:
|
|
return // invalid, ignore
|
|
}
|
|
|
|
// data consists of ; and 0-9 , anything else terminates the sequence
|
|
var type_ rune
|
|
for {
|
|
r, _, err := buf.ReadRune()
|
|
if err != nil {
|
|
return result, err
|
|
}
|
|
if r == ';' || ('0' <= r && r <= '9') {
|
|
ansiBuf.WriteRune(r)
|
|
} else {
|
|
type_ = r
|
|
break
|
|
}
|
|
}
|
|
|
|
var r rune
|
|
switch type_ {
|
|
case 'R':
|
|
if initial == '[' {
|
|
// DSR CPR response; if we can't parse it, just ignore it
|
|
// (do not return an error here because that would stop ioloop())
|
|
if cpos, err := parseCPRResponse(ansiBuf.Bytes()); err == nil {
|
|
return readResult{r: 0, ok: false, pos: &cpos}, nil
|
|
}
|
|
}
|
|
case 'D':
|
|
if altModifierEnabled(ansiBuf.Bytes()) {
|
|
r = MetaBackward
|
|
} else {
|
|
r = CharBackward
|
|
}
|
|
case 'C':
|
|
if altModifierEnabled(ansiBuf.Bytes()) {
|
|
r = MetaForward
|
|
} else {
|
|
r = CharForward
|
|
}
|
|
case 'A':
|
|
r = CharPrev
|
|
case 'B':
|
|
r = CharNext
|
|
case 'H':
|
|
r = CharLineStart
|
|
case 'F':
|
|
r = CharLineEnd
|
|
case '~':
|
|
if initial == '[' {
|
|
switch string(ansiBuf.Bytes()) {
|
|
case "3":
|
|
r = MetaDeleteKey // this is the key typically labeled "Delete"
|
|
case "1", "7":
|
|
r = CharLineStart // "Home" key
|
|
case "4", "8":
|
|
r = CharLineEnd // "End" key
|
|
}
|
|
}
|
|
case 'Z':
|
|
if initial == '[' {
|
|
r = MetaShiftTab
|
|
}
|
|
}
|
|
|
|
if r != 0 {
|
|
return readResult{r: r, ok: true}, nil
|
|
}
|
|
return // default: no interpretable rune value
|
|
}
|
|
|
|
func consumeAltSequence(buf *bufio.Reader) (result readResult, err error) {
|
|
initial, _, err := buf.ReadRune()
|
|
if err != nil {
|
|
return
|
|
}
|
|
if initial != '[' {
|
|
return
|
|
}
|
|
second, _, err := buf.ReadRune()
|
|
if err != nil {
|
|
return
|
|
}
|
|
switch second {
|
|
case 'D':
|
|
return readResult{r: MetaBackward, ok: true}, nil
|
|
case 'C':
|
|
return readResult{r: MetaForward, ok: true}, nil
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
|
|
func altModifierEnabled(payload []byte) bool {
|
|
// https://www.xfree86.org/current/ctlseqs.html ; modifier keycodes
|
|
// go after the semicolon, e.g. Alt-LeftArrow is `\x1b[1;3D` in VTE
|
|
// terminals, where 3 indicates Alt
|
|
if semicolonIdx := bytes.IndexByte(payload, ';'); semicolonIdx != -1 {
|
|
if string(payload[semicolonIdx+1:]) == "3" {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func parseCPRResponse(payload []byte) (cursorPosition, error) {
|
|
if semicolonIdx := bytes.IndexByte(payload, ';'); semicolonIdx != -1 {
|
|
if row, err := strconv.Atoi(string(payload[:semicolonIdx])); err == nil {
|
|
if col, err := strconv.Atoi(string(payload[semicolonIdx+1:])); err == nil {
|
|
return cursorPosition{row: row, col: col}, nil
|
|
}
|
|
}
|
|
}
|
|
return cursorPosition{-1, -1}, invalidCPR
|
|
}
|
|
|
|
func (t *terminal) Bell() {
|
|
t.Write([]byte{CharBell})
|
|
}
|
|
|
|
func (t *terminal) Close() error {
|
|
t.closeOnce.Do(func() {
|
|
t.waitForDSR()
|
|
close(t.stopChan)
|
|
// don't close outChan; outChan results should always be valid.
|
|
// instead we always select on both outChan and stopChan
|
|
t.closeErr = t.ExitRawMode()
|
|
})
|
|
return t.closeErr
|
|
}
|
|
|
|
func (t *terminal) SetConfig(c *Config) error {
|
|
t.cfg.Store(c)
|
|
return nil
|
|
}
|
|
|
|
func (t *terminal) GetConfig() *Config {
|
|
return t.cfg.Load()
|
|
}
|
|
|
|
// OnSizeChange gets the current terminal size and caches it
|
|
func (t *terminal) OnSizeChange() {
|
|
cfg := t.GetConfig()
|
|
width, height := cfg.FuncGetSize()
|
|
t.dimensions.Store(&termDimensions{
|
|
width: width,
|
|
height: height,
|
|
})
|
|
}
|
|
|
|
// GetWidthHeight returns the cached width, height values from the terminal
|
|
func (t *terminal) GetWidthHeight() (width, height int) {
|
|
dimensions := t.dimensions.Load()
|
|
return dimensions.width, dimensions.height
|
|
}
|