diff --git a/go.mod b/go.mod index 342c7d7..1b0599e 100644 --- a/go.mod +++ b/go.mod @@ -5,6 +5,7 @@ go 1.19 require ( github.com/alessio/shellescape v1.4.2 github.com/creack/pty v1.1.18 + github.com/danielgatis/go-vte v1.0.6 github.com/fatih/color v1.15.0 github.com/mattn/go-isatty v0.0.19 github.com/pbnjay/memory v0.0.0-20210728143218-7b4eea64cf58 @@ -14,6 +15,7 @@ require ( golang.org/x/exp v0.0.0-20230905200255-921286631fa9 golang.org/x/sys v0.12.0 golang.org/x/term v0.12.0 + modernc.org/mathutil v1.6.0 modernc.org/memory v1.7.1 ) @@ -22,6 +24,7 @@ require ( github.com/lufia/plan9stats v0.0.0-20230326075908-cb1d2100619a // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/shoenig/go-m1cpu v0.1.6 // indirect github.com/tklauser/go-sysconf v0.3.12 // indirect github.com/tklauser/numcpus v0.6.1 // indirect diff --git a/go.sum b/go.sum index c86f0f6..1295e63 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/alessio/shellescape v1.4.2 h1:MHPfaU+ddJ0/bYWpgIeUnQUqKrlJ1S7BfEYPM4u github.com/alessio/shellescape v1.4.2/go.mod h1:PZAiSCk0LJaZkiCSkPv8qIobYglO3FPpyFjDCtHLS30= github.com/creack/pty v1.1.18 h1:n56/Zwd5o6whRC5PMGretI4IdRLlmBXYNjScPaBgsbY= github.com/creack/pty v1.1.18/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4= +github.com/danielgatis/go-vte v1.0.6 h1:jSEGG0c3Ax8epsIKLWSeUjY4qgXxmKlBCghxK81YRrY= +github.com/danielgatis/go-vte v1.0.6/go.mod h1:B3jdQWPpcskSbJTMTqb+slToseRGyT03pvjb7ILiYJE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= @@ -31,6 +33,7 @@ github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:Om github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b h1:0LFwY6Q3gMACTjAbMZBjXAqTOzOwFaj2Ld6cjeQ7Rig= github.com/power-devops/perfstat v0.0.0-20221212215047-62379fc7944b/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/shirou/gopsutil/v3 v3.23.8 h1:xnATPiybo6GgdRoC4YoGnxXZFRc3dqQTGi73oLvvBrE= github.com/shirou/gopsutil/v3 v3.23.8/go.mod h1:7hmCaBn+2ZwaZOr6jmPBZDfawwMGuo1id3C6aM8EDqQ= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= @@ -72,5 +75,6 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= modernc.org/memory v1.7.1 h1:9J+2/GKTlV503mk3yv8QJ6oEpRCUrRy0ad8TXEPoV8M= modernc.org/memory v1.7.1/go.mod h1:NO4NVCQy0N7ln+T9ngWqOQfi7ley4vpwvARR+Hjw95E= diff --git a/main.go b/main.go index 4566c3d..bc92297 100644 --- a/main.go +++ b/main.go @@ -66,8 +66,15 @@ func writeOut(out *Output) { func toForeground(proc *ProcessResult) (exitCode int) { proc.output.partsMutex.Lock() + + proc.output.shouldPassToParent.value = true + proc.output.shouldPassToParent.becameTrue <- struct{}{} + if !stdoutAndStderrAreTheSame() { + proc.output.shouldPassToParent.becameTrue <- struct{}{} + } + writeOut(proc.output) - proc.output.shouldPassToParent = true + proc.output.partsMutex.Unlock() return <-proc.exitCode // block until the process exits @@ -295,6 +302,7 @@ func main() { createLimitServer() } + // Use chann.New() instead of make(chan *ProcessResult) to have a channel with an unbounded dynamically-sized buffer processes := chann.New[*ProcessResult]() go func() { defer processes.Close() diff --git a/recursivetasklimit.go b/recursivetasklimit.go index 5c635d9..a5d22ff 100644 --- a/recursivetasklimit.go +++ b/recursivetasklimit.go @@ -59,7 +59,7 @@ func createLimitServer() { // if we've previously crashed (or exited unexpectedly) there could be an old socket file // left over at the same location if PID rollover happens. Let's try to remove it then to - // be safe if case it exists + // be safe in case it exists _ = os.Remove(listenPath) mustSetenv(EnvGparallelChildLimitSocket, listenPath) @@ -142,12 +142,10 @@ var recursiveTaskLimitClient = onceValue(func() (client struct { } mutex.Unlock() - ch := make(chan error) - go func() { ch <- readOneByte(conn) }() select { case <-ctx.Done(): err = ctx.Err() - case err = <-ch: + case err = <-toChannel(func() error { return readOneByte(conn) }): } mutex.Lock() diff --git a/runner.go b/runner.go index 49c5aa9..a512b96 100644 --- a/runner.go +++ b/runner.go @@ -16,22 +16,34 @@ import ( "github.com/alessio/shellescape" ptyPkg "github.com/creack/pty" + "github.com/karolba/gparallel/terminalscreen" "github.com/shirou/gopsutil/v3/process" "golang.org/x/exp/slices" "golang.org/x/sys/unix" ) -const MAXBUF = 32 * 1024 +const MAXBUF = 16 * 1024 type Output struct { parts []byte partsMutex sync.Mutex - shouldPassToParent bool - stdoutPipeOrPty *os.File - stderrPipeOrPty *os.File - winchSignal chan os.Signal - streamClosed chan struct{} - allocator chunkAllocator + shouldPassToParent struct { + value bool + becameTrue chan struct{} + } + stdoutPipeOrPty *os.File + stderrPipeOrPty *os.File + stdoutVirtualScreen *terminalscreen.Screen + stderrVirtualScreen *terminalscreen.Screen + winchSignal chan os.Signal + streamClosed chan struct{} + allocator chunkAllocator +} + +func NewOutput() *Output { + o := &Output{} + o.shouldPassToParent.becameTrue = make(chan struct{}, 2) + return o } type ProcessResult struct { @@ -70,11 +82,29 @@ func (proc *ProcessResult) wait() error { return proc.cmd.Wait() } -func (out *Output) appendOrWrite(buf []byte, dataFromFd int) { +func (out *Output) appendOrWrite(buf []byte, dataFromFd int, assumedShouldPassToParent bool, screen *terminalscreen.Screen) { out.partsMutex.Lock() defer out.partsMutex.Unlock() - if out.shouldPassToParent { + if out.shouldPassToParent.value != assumedShouldPassToParent && screen != nil { + // We know for sure (due to using the mutex) this is now a foreground process that should pass its data + // directly to stdout/stderr - but our calling function doesn't know that yet - oops. + // + // The chunk storage for this process no longer exists, so can't do out.appendChunk() + // + // This wouldn't be a problem (just write to stdout/stderr!) if not for the virtual terminal screen emulation + // present in interactive mode. Writing child's output to stdout/stderr before the screen dumps all its contents + // is bound to produce out-of-order output. + // + // Let's just pass it along to the virtual screen for later processing then. + // + // Could probably get rid of this with some heavy refactoring, to make this function less bloated. + // (if only golang supported using sync.RWMutexes in sync.Cond, this whole situation wouldn't have happened) + screen.Advance(buf) + return + } + + if out.shouldPassToParent.value { _, err := standardFdToFile[dataFromFd].Write(buf) if err != nil { log.Fatalf("Syscall write to fd %d: %v\n", dataFromFd, err) @@ -101,36 +131,109 @@ func waitIfUsingTooMuchMemory(willSaveBytes int64, out *Output) { } } -func readContinuouslyTo(stream io.ReadCloser, out *Output, fileDescriptor int) { - buffer := make([]byte, MAXBUF) +func isEOFFromPtmxDevice(err error) bool { + if err == nil { + return false + } - for { - count, err := stream.Read(buffer) + if err == io.EOF { + return true + } + + var pathError *os.PathError + if errors.As(err, &pathError) && pathError.Err == syscall.EIO { + // Returning EIO is Linux's way of saying the other end is closed when reading from a ptmx: + // https://github.com/creack/pty/issues/21 + // On BSDs (and macOS) this is signaled by a simple EOF + return true + } + + return false +} - if count > 0 { - waitIfUsingTooMuchMemory(chunkSizeWithHeader(buffer[:count]), out) - out.appendOrWrite(buffer[:count], fileDescriptor) +func readContinuouslyToChannel(stream io.Reader, readResult chan withError[[]byte]) { + // Make two buffers, to operate on one and simultaneously read into the other one, without introducing + // race conditions + buffer1 := make([]byte, MAXBUF) + buffer2 := make([]byte, MAXBUF) + + for { + count, err := stream.Read(buffer1) + if err != nil { + readResult <- withError[[]byte]{err: err} + return } + readResult <- withError[[]byte]{value: buffer1[:count]} + count, err = stream.Read(buffer2) if err != nil { - if err == io.EOF { - haveToClose("child stdout/stderr after EOF", stream) - break - } - if errors.Is(err, fs.ErrClosed) { - break + readResult <- withError[[]byte]{err: err} + return + } + readResult <- withError[[]byte]{value: buffer2[:count]} + } +} + +func readContinuouslyTo(stream io.ReadCloser, throughVirtualScreen *terminalscreen.Screen, out *Output, fileDescriptor int) { + // Track out.shouldPassToParent ourselves (and update via a channel) to avoid race conditions + //shouldPassToParent := false // TODO: set to out.shouldPassToParent.value + shouldPassToParent := out.shouldPassToParent.value + + readResult := make(chan withError[[]byte]) + go readContinuouslyToChannel(stream, readResult) + + for { + var buffer []byte + var err error + + select { + case countWithError := <-readResult: + buffer = countWithError.value + err = countWithError.err + case <-out.shouldPassToParent.becameTrue: + shouldPassToParent = true + if throughVirtualScreen != nil { + // We became the foreground process ourselves - dump the last visible screen lines (non-scrollback) + // to the output. + throughVirtualScreen.End() } - var pathError *os.PathError - if errors.As(err, &pathError) && pathError.Err == syscall.EIO { - // Returning EIO is Linux's way of saying the other end is closed when reading from a ptmx: - // https://github.com/creack/pty/issues/21 - haveToClose("child stdout/stderr after EIO", stream) - break + } + + if len(buffer) > 0 { + if throughVirtualScreen == nil || shouldPassToParent { + waitIfUsingTooMuchMemory(chunkSizeWithHeader(buffer), out) + out.appendOrWrite(buffer, fileDescriptor, shouldPassToParent, throughVirtualScreen) + } else { + throughVirtualScreen.Advance(buffer) } + } + + if throughVirtualScreen != nil && len(throughVirtualScreen.QueuedScrollbackOutput) > 0 { + waitIfUsingTooMuchMemory(chunkSizeWithHeader(throughVirtualScreen.QueuedScrollbackOutput), out) + out.appendOrWrite(throughVirtualScreen.QueuedScrollbackOutput, fileDescriptor, shouldPassToParent, throughVirtualScreen) + throughVirtualScreen.QueuedScrollbackOutput = []byte{} + } + + if errors.Is(err, fs.ErrClosed) { + break + } else if isEOFFromPtmxDevice(err) { + haveToClose("child stdout/stderr after EIO", stream) + break + } else if err != nil { log.Fatalf("error from read: %v\n", err) } } + // The process died (or at least closed its output) so need to dump the last visible screen lines into the output + if throughVirtualScreen != nil { + if !throughVirtualScreen.Ended { + throughVirtualScreen.End() + } + waitIfUsingTooMuchMemory(chunkSizeWithHeader(throughVirtualScreen.QueuedScrollbackOutput), out) + out.appendOrWrite(throughVirtualScreen.QueuedScrollbackOutput, fileDescriptor, shouldPassToParent, nil) + throughVirtualScreen.QueuedScrollbackOutput = []byte{} + } + out.streamClosed <- struct{}{} } @@ -166,7 +269,7 @@ func createPty(winSize *ptyPkg.Winsize) (pty, tty *os.File, err error) { } // the pty package opens /dev/ptmx without O_NONBLOCK. This makes go spawn a lot of threads - // when reading from lots of ptys in goroutines. Let's work around that by duping the file desctiptor + // when reading from lots of ptys in goroutines. Let's work around that by duping the file descriptor // into a new one, and creating a new *os.File object with a new async fd asyncPtyFd, err := unix.FcntlInt(pty.Fd(), unix.F_DUPFD_CLOEXEC, 0) if err != nil { @@ -188,13 +291,15 @@ func createPty(winSize *ptyPkg.Winsize) (pty, tty *os.File, err error) { } func runInteractive(cmd *exec.Cmd) *Output { + // set GOMAXPROCS to 1 to make the process running executeAndFlushTty a bit lighter - it's a really lightweight + // job, so it shouldn't consume much resources at all cmd.Env = os.Environ() if originalGoMaxProcs, exists := os.LookupEnv("GOMAXPROCS"); exists { cmd.Env = append(cmd.Env, fmt.Sprintf("_GPARALLEL_ORIGINAL_GOMAXPROCS=%s", originalGoMaxProcs)) } cmd.Env = append(cmd.Env, "GOMAXPROCS=1") - out := &Output{} + out := NewOutput() var stdoutTty, stderrTty *os.File size, err := ptyPkg.GetsizeFull(os.Stdout) @@ -202,6 +307,13 @@ func runInteractive(cmd *exec.Cmd) *Output { log.Fatalf("Could not get terminal size: %v\n", err) } + out.stdoutVirtualScreen = terminalscreen.NewScreen(int(size.Cols), int(size.Rows)) + if stdoutAndStderrAreTheSame() { + out.stderrVirtualScreen = out.stdoutVirtualScreen + } else { + out.stderrVirtualScreen = terminalscreen.NewScreen(int(size.Cols), int(size.Rows)) + } + out.stdoutPipeOrPty, stdoutTty, err = createPty(size) if err != nil { log.Fatalf("Couldn't create a pty for %v's stdout: %v\n", cmd.Args, err) @@ -228,18 +340,24 @@ func runInteractive(cmd *exec.Cmd) *Output { signal.Notify(out.winchSignal, syscall.SIGWINCH) go func() { for range out.winchSignal { - // TODO: this should handle just one of stderr/stdout being closed + // TODO: this should handle just one of stderr/stdout being closed and the other resizing size, err := ptyPkg.GetsizeFull(os.Stdout) if err != nil { log.Fatalf("Could not get terminal size on sigwinch: %v\n", err) } + // Resize the in-kernel virtual pty (to propagate the resize) _ = ptyPkg.Setsize(out.stdoutPipeOrPty, size) - if !stdoutAndStderrAreTheSame() { _ = ptyPkg.Setsize(out.stderrPipeOrPty, size) } + + // Resize our own in-process terminal screen representation + out.stdoutVirtualScreen.Resize(int(size.Rows), int(size.Cols)) + if !stdoutAndStderrAreTheSame() { + out.stderrVirtualScreen.Resize(int(size.Rows), int(size.Cols)) + } } }() @@ -251,7 +369,7 @@ func runInteractive(cmd *exec.Cmd) *Output { err = cmd.Start() if err != nil { - // TODO: take the :2 only if --_execute-and-flush-tty is used - if not using it is even implemented + // TODO: take the :2 in the error message only if --_execute-and-flush-tty is used - if not using it is even implemented log.Fatalf("Could not start %v: %v\n", shellescape.QuoteCommand(cmd.Args[2:]), err) } @@ -261,8 +379,8 @@ func runInteractive(cmd *exec.Cmd) *Output { func runNonInteractive(cmd *exec.Cmd) *Output { var err error var stdoutWritePipe, stderrWritePipe *os.File - out := &Output{} + out := NewOutput() out.stdoutPipeOrPty, stdoutWritePipe, err = os.Pipe() if err != nil { log.Fatalf("Could not create a pipe for %v's stdout: %v\n", cmd.Args, err) @@ -327,9 +445,9 @@ func runWithStdin(command []string, stdin io.Reader) (result *ProcessResult) { } result.output.streamClosed = make(chan struct{}, 2) - go readContinuouslyTo(result.output.stdoutPipeOrPty, result.output, syscall.Stdout) + go readContinuouslyTo(result.output.stdoutPipeOrPty, result.output.stdoutVirtualScreen, result.output, syscall.Stdout) if !stdoutAndStderrAreTheSame() { - go readContinuouslyTo(result.output.stderrPipeOrPty, result.output, syscall.Stderr) + go readContinuouslyTo(result.output.stderrPipeOrPty, result.output.stderrVirtualScreen, result.output, syscall.Stderr) } result.startedAt = time.Now() diff --git a/terminalscreen/character.go b/terminalscreen/character.go new file mode 100644 index 0000000..ca0a6fd --- /dev/null +++ b/terminalscreen/character.go @@ -0,0 +1,22 @@ +package terminalscreen + +type Character struct { + rune rune + + // TODO: dedup those + extraEscapeSequences string + + sgr SGRList +} + +func (c *Character) addSGR(sgr SelectGraphicRenditionAttribute) { + if sgr.isUnsetAll() { + c.sgr = []SelectGraphicRenditionAttribute{} + } else { + sgr.addToSGRAttributeList(&c.sgr) + } +} + +func (c *Character) clearSGR() { + c.sgr = []SelectGraphicRenditionAttribute{} +} diff --git a/terminalscreen/escapesequences.go b/terminalscreen/escapesequences.go new file mode 100644 index 0000000..32de459 --- /dev/null +++ b/terminalscreen/escapesequences.go @@ -0,0 +1,272 @@ +package terminalscreen + +import ( + "bytes" + "fmt" + "log" + "os" + "strconv" + + "github.com/danielgatis/go-vte" +) + +// https://vt100.net/docs/vt510-rm/chapter4.html +const ESC = "\033" +const OSC_START = "\033]" // "Operating system command" +const CSI_START = "\033[" // "Control sequence introducer" +const DCS_START = "\033P" // "Device control string" + +var shouldBeVerboseWhenEncounteringAnUnknownEscapeSequence = os.Getenv("GPARALLEL_DEBUG_SHOW_UNHANDLED_ESCAPE_SEQUENCES") != "" + +type EscapeSequenceParser struct { + vteParser *vte.Parser +} + +type EscapeSequenceParserOutput interface { + outNormalCharacter(b rune) + outRelativeMoveCursorVertical(howMany int) + outRelativeMoveCursorHorizontal(howMany int) + outAbsoluteMoveCursorVertical(y int) + outAbsoluteMoveCursorHorizontal(x int) + outDeleteLeft(howMany int) + outUnhandledEscapeSequence(s string) + outSelectGraphicRenditionAttribute(sgr SelectGraphicRenditionAttribute) +} + +type vtePerformer struct{ out EscapeSequenceParserOutput } + +func NewEscapeSequenceParser(outOpts EscapeSequenceParserOutput) EscapeSequenceParser { + return EscapeSequenceParser{vteParser: vte.NewParser(&vtePerformer{ + out: outOpts, + })} +} + +func (escapeSequenceParser EscapeSequenceParser) Advance(bytes []byte) { + for _, b := range bytes { + escapeSequenceParser.vteParser.Advance(b) + } +} + +// Draw a character to the screen and update states +func (p *vtePerformer) Print(r rune) { + //if r == ' ' { + // log.Printf("[Print] space\n") + //} else { + // log.Printf("[Print] '%c'\n", r) + //} + + p.out.outNormalCharacter(r) +} + +// Execute a C0 or C1 control function +func (p *vtePerformer) Execute(b byte) { + if b == '\t' { + // TODO: this... it's not even a tab + p.out.outNormalCharacter(' ') + p.out.outNormalCharacter(' ') + p.out.outNormalCharacter(' ') + p.out.outNormalCharacter(' ') + p.out.outNormalCharacter(' ') + p.out.outNormalCharacter(' ') + p.out.outNormalCharacter(' ') + p.out.outNormalCharacter(' ') + //log.Printf("[Execute] tab\n") + } else if b == '\n' { + p.out.outAbsoluteMoveCursorHorizontal(0) + p.out.outRelativeMoveCursorVertical(1) + } else if b == '\v' { + log.Printf("[Execute] TODO: vertical tab\n") + } else if b == '\r' { + p.out.outAbsoluteMoveCursorHorizontal(0) + } else if b == '\b' { + p.out.outDeleteLeft(1) + //log.Printf("[Execute] delete\n") + } else { + log.Printf("[UnhandledExecute] TODO: Execute '%c' (%b)\n", b, b) + } + + //fmt.Printf("TODO: Execute '%c'", b) +} + +// Pass bytes as part of a device control string to the handle chosen in hook. C0 controls will also be passed to the handler. +func (p *vtePerformer) Put(b byte) { + if shouldBeVerboseWhenEncounteringAnUnknownEscapeSequence { + log.Printf("[UnhandledPut] %02x %c\n", b, rune(b)) + } + p.out.outUnhandledEscapeSequence(string(b)) + + //fmt.Printf("%c", b) +} + +// Called when a device control string is terminated. +// The previously selected handler should be notified that the DCS has terminated. +func (p *vtePerformer) Unhook() { + //log.Printf("[Unhook]\n") +} + +// Converts escape sequence params split by go-vte into "params" back into their original encoded form +func paramsToString[T uint16 | byte](params [][]T) string { + var joinedParams bytes.Buffer + for i, paramSet := range params { + if i != 0 { + joinedParams.WriteByte(';') + } + + for j, param := range paramSet { + if j != 0 { + joinedParams.WriteByte(':') + } + joinedParams.WriteString(strconv.Itoa(int(param))) + } + } + return joinedParams.String() +} + +func splitIntermediates(intermediates []byte) (privateMarkers []byte, realIntermediates []byte) { + for _, b := range intermediates { + if b >= 0x30 && b <= 0x3f { + privateMarkers = append(privateMarkers, b) + } else { + realIntermediates = append(realIntermediates, b) + } + } + return privateMarkers, realIntermediates +} + +// Invoked when a final character arrives in first part of device control string. +// +// The control function should be determined from the private marker, final character, and execute with a parameter list. +// A handler should be selected for remaining characters in the string; the handler function should subsequently be called +// by put for every character in the control string. +// +// The ignore flag indicates that more than two intermediates arrived and subsequent characters were ignored. +func (p *vtePerformer) Hook(params [][]uint16, intermediates []byte, ignore bool, final rune) { + privateMarkers, realIntemediates := splitIntermediates(intermediates) + + if shouldBeVerboseWhenEncounteringAnUnknownEscapeSequence { + log.Printf("[UnhandledHook] params=%v, intermediates=%v, ignore=%v, r=%c\n", params, intermediates, ignore, final) + } + + p.out.outUnhandledEscapeSequence(fmt.Sprintf("%s%s%s%s%c", + DCS_START, + privateMarkers, + paramsToString(params), + realIntemediates, + final)) +} + +// Dispatch an operating system command. +func (p *vtePerformer) OscDispatch(params [][]byte, bellTerminated bool) { + if shouldBeVerboseWhenEncounteringAnUnknownEscapeSequence { + log.Printf("[UnhandledOscDispatch] params=%v, bellTerminated=%v\n", params, bellTerminated) + } + + p.out.outUnhandledEscapeSequence(fmt.Sprintf("%s%s", + OSC_START, + bytes.Join(params, []byte{';'}))) +} + +func numericParams(input [][]uint16) []int { + // TODO: make this nicer + var result []int + + for _, p1 := range input { + for _, p2 := range p1 { + result = append(result, int(p2)) + } + } + + if len(result) == 0 { + // TODO: yeah this + return []int{0} + } + + return result +} + +// A final character has arrived for a CSI sequence +// +// The ignore flag indicates that either more than two intermediates arrived or the number of parameters exceeded +// the maximum supported length, and subsequent characters were ignored. +func (p *vtePerformer) CsiDispatch(params [][]uint16, intermediates []byte, ignore bool, final rune) { + privateMarkers, realIntemediates := splitIntermediates(intermediates) + + // Cursor Up (CUU): ESC [ Ⓝ A - https://terminalguide.namepad.de/seq/csi_ca/ + if bytes.Equal(intermediates, []byte{}) && final == 'A' { + p.out.outRelativeMoveCursorVertical(-numericParams(params)[0]) + return + } + + // Cursor Down (CUD): ESC [ Ⓝ B - https://terminalguide.namepad.de/seq/csi_cb/ + if bytes.Equal(intermediates, []byte{}) && final == 'B' { + p.out.outRelativeMoveCursorVertical(numericParams(params)[0]) + return + } + + // Cursor Right (CUF): ESC [ Ⓝ C - https://terminalguide.namepad.de/seq/csi_cc/ + if bytes.Equal(intermediates, []byte{}) && final == 'C' { + p.out.outRelativeMoveCursorHorizontal(numericParams(params)[0]) + return + } + + // Cursor Left (CUB): ESC [ Ⓝ D - https://terminalguide.namepad.de/seq/csi_cd/ + if bytes.Equal(intermediates, []byte{}) && final == 'D' { + p.out.outRelativeMoveCursorHorizontal(-numericParams(params)[0]) + return + } + + // Set Cursor Position (CUP): ESC [ Ⓝ ; Ⓝ H - https://terminalguide.namepad.de/seq/csi_ch/ + if bytes.Equal(intermediates, []byte{}) && final == 'H' { + coords := numericParams(params) + // The coordinates in here are 1-based, but we use 0-based coordinates - hence the minus one + x := getOrDefault(coords, 0) - 1 + y := getOrDefault(coords, 1) - 1 + p.out.outAbsoluteMoveCursorHorizontal(x) + p.out.outAbsoluteMoveCursorHorizontal(y) + return + } + + // Cursor Horizontal Position Absolute (HPA) (TODO more details) + if bytes.Equal(intermediates, []byte{}) && (final == 'G' || final == '`') { + // The coordinates in here are 1-based, but we use 0-based coordinates - hence the minus one + p.out.outAbsoluteMoveCursorHorizontal(numericParams(params)[0] - 1) + return + } + + // Cursor Vertical Position Absolute (VPA) (TODO more details) + if bytes.Equal(intermediates, []byte{}) && final == 'd' { + // The coordinates in here are 1-based, but we use 0-based coordinates - hence the minus one + p.out.outAbsoluteMoveCursorVertical(numericParams(params)[0] - 1) + return + } + + // SGR - Select Graphic Rendition - https://terminalguide.namepad.de/seq/csi_m/ + if bytes.Equal(intermediates, []byte{}) && final == 'm' { + p.out.outSelectGraphicRenditionAttribute(params) + return + } + + if shouldBeVerboseWhenEncounteringAnUnknownEscapeSequence { + log.Printf("[UnhandledCsiDispatch] params=%v, intermediates=%v, ignore=%v, r=%c\n", params, intermediates, ignore, final) + } + + p.out.outUnhandledEscapeSequence(fmt.Sprintf("%s%s%s%s%c", + CSI_START, + privateMarkers, + paramsToString(params), + realIntemediates, + final)) +} + +// The final character of an escape sequence has arrived. +// The ignore flag indicates that more than two intermediates arrived and subsequent characters were ignored. +func (p *vtePerformer) EscDispatch(intermediates []byte, ignore bool, final byte) { + if shouldBeVerboseWhenEncounteringAnUnknownEscapeSequence { + log.Printf("[UnhandledEscDispatch] intermediates=%v, ignore=%v, byte=%02x\n", intermediates, ignore, final) + } + + p.out.outUnhandledEscapeSequence(fmt.Sprintf("%s%s%c", + ESC, + intermediates, + final)) +} diff --git a/terminalscreen/line.go b/terminalscreen/line.go new file mode 100644 index 0000000..b167b3a --- /dev/null +++ b/terminalscreen/line.go @@ -0,0 +1,36 @@ +package terminalscreen + +type Line struct { + index int + + // characters in a Line are represented as strings (and not runes) because we treat escape sequences as parts of + // their corresponding characters. + characters []Character +} + +func NewLine(index int) *Line { + return &Line{index: index} +} + +func (l *Line) characterAt(i int) *Character { + l.characters = ensureAtLeastLength(l.characters, i+1) + return &l.characters[i] +} + +func (l *Line) lengthWithoutTrailingSpacesAndEmptyRunes() int { + var emptyRune rune + + for i := len(l.characters) - 1; i >= 0; i-- { + if l.characters[i].rune == ' ' { + continue + } + if l.characters[i].rune == emptyRune { + continue + } + if l.characters[i].extraEscapeSequences != "" { + continue + } + return i + 1 + } + return 0 +} diff --git a/terminalscreen/screen.go b/terminalscreen/screen.go new file mode 100644 index 0000000..d114725 --- /dev/null +++ b/terminalscreen/screen.go @@ -0,0 +1,234 @@ +package terminalscreen + +import ( + "log" + "strconv" +) + +type Screen struct { + lines []*Line + width, maxHeight int + positionX, positionY int + + parser EscapeSequenceParser + + Ended bool + QueuedScrollbackOutput []byte + + currentSGRs SGRList +} + +func NewScreen(width int, height int) *Screen { + screen := &Screen{ + width: width, + maxHeight: height, + lines: []*Line{NewLine(0)}, + } + screen.parser = NewEscapeSequenceParser(screen) + if screen.maxHeight <= 0 { + screen.maxHeight = 1 + } + if screen.width <= 0 { + screen.width = 1 + } + return screen +} + +func (s *Screen) Advance(b []byte) { + //log.Printf("call to Screen.Advance(%v)\n", b) + s.parser.Advance(b) +} + +func (s *Screen) Resize(width, height int) { +} + +func (s *Screen) currentLine() *Line { + return s.lines[s.positionY-s.lines[0].index] +} + +func (s *Screen) firstLineIndex() int { + return s.lines[0].index +} + +func (s *Screen) currentScreenHeight() int { + return len(s.lines) +} + +func (s *Screen) sendLineToScrollbackBuffer(line *Line) { + didSetSGR := false + + // Prepend every non-first line with a line terminator to make the scrollback buffer not end with a newline. + if len(s.QueuedScrollbackOutput) > 0 { + s.appendToScrollback("\n") + } + + previousCharacter := Character{} + for _, character := range line.characters { + s.appendToScrollback(character.extraEscapeSequences) + + if !character.sgr.equals(previousCharacter.sgr) { + // Reset SGR + s.appendToScrollback("\033[0m") + + // Set SGR again + for _, sgr := range character.sgr { + s.appendToScrollback(sgr.toCSI()) + } + + didSetSGR = true + } + + s.appendToScrollback(string(character.rune)) + + previousCharacter = character + } + + if didSetSGR { + // Reset SGR + s.appendToScrollback("\033[0m") + } +} + +func (s *Screen) End() { + if s.Ended { + log.Panicln("Screen.End() called twice") + } + + s.Ended = true + + for _, line := range s.lines { + s.sendLineToScrollbackBuffer(line) + } + + moveRightBy := s.currentLine().lengthWithoutTrailingSpacesAndEmptyRunes() - s.positionX + if moveRightBy > 0 { + s.appendToScrollback("\033[" + strconv.Itoa(moveRightBy) + "C") + } else if moveRightBy < 0 { + s.appendToScrollback("\033[" + strconv.Itoa(-moveRightBy) + "D") + } + + moveDownBy := s.currentScreenHeight() - s.positionYInVisibleScreenCoordinates() - 1 + if moveDownBy > 0 { + s.appendToScrollback("\033[" + strconv.Itoa(moveDownBy) + "B") + } else if moveDownBy < 0 { + s.appendToScrollback("\033[" + strconv.Itoa(-moveDownBy) + "A") + } + + s.lines = []*Line{} +} + +func (s *Screen) nextLine() { + //log.Printf("call to Screen.nextLine()\n") + + if s.positionYInVisibleScreenCoordinates() < s.currentScreenHeight()-1 { + s.positionY++ + } else { + lastIndex := s.lines[len(s.lines)-1].index + s.lines = append(s.lines, NewLine(lastIndex+1)) + } + + // If there's more than s.maxHeight lines, send the first line to the scrollback buffer and remove it + // from the virtual screen. + if s.currentScreenHeight() > s.maxHeight { + s.sendLineToScrollbackBuffer(s.lines[0]) + s.lines = append([]*Line{}, s.lines[1:]...) + } +} + +func (s *Screen) prevLine() { + if s.positionY <= s.firstLineIndex() { + // TODO: negative-index lines? + return + } + s.positionY-- +} + +func (s *Screen) nextCharacter() { + // Don't care about max line length - we pretend the screen is infinitely wide. + s.positionX += 1 +} + +func (s *Screen) prevCharacter() { + s.positionX -= 1 + if s.positionX < 0 { + s.positionX = 0 + } +} + +func (s *Screen) setCurrentCharacterTo(r rune) { + s.currentLine().characterAt(s.positionX).rune = r + if s.currentSGRs == nil { + s.currentLine().characterAt(s.positionX).clearSGR() + } else { + for _, sgr := range s.currentSGRs { + s.currentLine().characterAt(s.positionX).addSGR(sgr) + } + } +} + +func (s *Screen) outNormalCharacter(b rune) { + s.setCurrentCharacterTo(b) + s.nextCharacter() +} + +func (s *Screen) outRelativeMoveCursorVertical(howMany int) { + // TODO: maybe this shouldn't iterate here + for i := 0; howMany > i; i++ { + s.nextLine() + } + for i := 0; howMany < i; i-- { + s.prevLine() + } +} + +func (s *Screen) outRelativeMoveCursorHorizontal(howMany int) { + for i := 0; howMany > i; i++ { + s.nextCharacter() + } + for i := 0; howMany < i; i-- { + s.prevCharacter() + } +} + +func (s *Screen) outAbsoluteMoveCursorVertical(y int) { + moveDownBy := y - s.positionYInVisibleScreenCoordinates() + s.outRelativeMoveCursorVertical(moveDownBy) +} + +func (s *Screen) outAbsoluteMoveCursorHorizontal(x int) { + s.positionX = x + if s.positionX < 0 { + s.positionX = 0 + } +} + +func (s *Screen) outDeleteLeft(howMany int) { + for i := 0; howMany > i; i++ { + s.prevCharacter() + s.setCurrentCharacterTo(' ') + if s.positionX == 0 { + break + } + } +} + +func (s *Screen) outUnhandledEscapeSequence(seq string) { + // append to the current character but don't move the cursor forward + s.currentLine().characterAt(s.positionX).extraEscapeSequences += seq +} + +func (s *Screen) outSelectGraphicRenditionAttribute(sgr SelectGraphicRenditionAttribute) { + if sgr.isUnsetAll() { + s.currentSGRs = []SelectGraphicRenditionAttribute{} + } else { + sgr.addToSGRAttributeList(&s.currentSGRs) + } +} + +func (s *Screen) appendToScrollback(str string) { + s.QueuedScrollbackOutput = append(s.QueuedScrollbackOutput, []byte(str)...) +} + +func (s *Screen) positionYInVisibleScreenCoordinates() int { + return s.positionY - s.firstLineIndex() +} diff --git a/terminalscreen/sgrattribute.go b/terminalscreen/sgrattribute.go new file mode 100644 index 0000000..682429a --- /dev/null +++ b/terminalscreen/sgrattribute.go @@ -0,0 +1,63 @@ +package terminalscreen + +import "golang.org/x/exp/slices" + +// SelectGraphicRenditionAttribute is a list representing a single SGR attribute +// - in the format the danielgatis/go-vte gives us. +type SelectGraphicRenditionAttribute [][]uint16 + +type SGRList []SelectGraphicRenditionAttribute + +func (s SelectGraphicRenditionAttribute) addToSGRAttributeList(sgrAttributeList *SGRList) { + // don't add the same SGR twice by removing the previous one from the slice + // TODO: make this more elegant + if len(s) > 0 && len(s[0]) > 0 { + for i, sgr := range *sgrAttributeList { + if len(sgr) > 0 && len(sgr[0]) > 0 && sgr[0][0] == s[0][0] { + *sgrAttributeList = append((*sgrAttributeList)[:i], (*sgrAttributeList)[i+1:]...) + } + } + } + *sgrAttributeList = append(*sgrAttributeList, s) +} + +func (s SelectGraphicRenditionAttribute) isUnsetAll() bool { + // https://terminalguide.namepad.de/seq/csi_sm/ + // "If no attribute is given or attribute = 0, unset all attributes." + if len(s) == 0 { + return true + } + if len(s) == 1 && len(s[0]) == 1 && s[0][0] == 0 { + return true + } + return false +} + +func (s SelectGraphicRenditionAttribute) toCSI() string { + return "\033[" + paramsToString(s) + "m" +} + +// TODO: the two following methods, is there a better way to do them in go? +func (s SelectGraphicRenditionAttribute) equals(other SelectGraphicRenditionAttribute) bool { + if len(s) != len(other) { + return false + } + for i, param := range s { + if !slices.Equal(param, other[i]) { + return false + } + } + return true +} + +func (s SGRList) equals(other SGRList) bool { + if len(s) != len(other) { + return false + } + for i, sgr := range s { + if !sgr.equals(other[i]) { + return false + } + } + return true +} diff --git a/terminalscreen/util.go b/terminalscreen/util.go new file mode 100644 index 0000000..1b2a4a1 --- /dev/null +++ b/terminalscreen/util.go @@ -0,0 +1,15 @@ +package terminalscreen + +func getOrDefault[T any](slice []T, index int) (result T) { + if index < len(slice) { + result = slice[index] + } + return result +} + +func ensureAtLeastLength[T any](slice []T, atLeastLength int) []T { + if len(slice) < atLeastLength { + slice = append(slice, make([]T, atLeastLength-len(slice))...) + } + return slice +} diff --git a/util.go b/util.go index b038db0..7d98f0e 100644 --- a/util.go +++ b/util.go @@ -122,3 +122,35 @@ func onceValue[T any](f func() T) func() T { return result } } + +func channel(f func()) <-chan struct{} { + ch := make(chan struct{}) + go func() { + f() + ch <- struct{}{} + }() + return ch +} + +func toChannel[T any](f func() T) <-chan T { + ch := make(chan T) + go func() { ch <- f() }() + return ch +} + +type withError[T any] struct { + value T + err error +} + +func toChannelWithError[T any](f func() (T, error)) <-chan withError[T] { + ch := make(chan withError[T]) + go func() { + val, err := f() + ch <- withError[T]{ + value: val, + err: err, + } + }() + return ch +}