Search code examples
gostdin

Wrapping os.Stdin with an io.TeeReader breaks the tty


Context: I am trying to write a small tool in Golang, which spawns a vim editor and tries to record all the keystrokes input by the user.

package main

import (
    "bufio"
    "fmt"
    "os"
    "os/exec"
)

func main() {
    cmd := exec.Command("vim", "test.txt")

    rdr := bufio.NewReader(os.Stdin)

    cmd.Stdin = rdr
    cmd.Stdout = os.Stdout
    if err := cmd.Run(); err != nil {
        fmt.Println("err", err)
        return
    }
}

The above piece of code works as expected, but I want to record the keystrokes into a temporary buffer, for this I am using an io.TeeReader which writes the os.Stdin contents into a bytes.Buffer. This seems to break the tty, vim starts behaving weirdly and doesn't differentiate between the insert mode and command mode. Also The Read method returned by the io.TeeReader hangs up and doesn't seem to return after vim is exited.

Example code:

package main

import (
    "bytes"
    "fmt"
    "io"
    "os"
    "os/exec"
)

func main() {
    cmd := exec.Command("vim", "test.txt")

    vimBuff := bytes.Buffer{}
    rdr := io.TeeReader(os.Stdin, &vimBuff)

    cmd.Stdin = rdr
    cmd.Stdout = os.Stdout
    if err := cmd.Run(); err != nil {
        fmt.Println("err", err)
        return
    }
}

Solution

  • The problem

    The reason is that Vim is a program which expects a real terminal — which might be a hardware device or an emulator such as xterm, GNOME Terminal, screen, tmux etc in order to work interactively.

    Normally, when you start Vim in your shell, these days with 99% confidence you're doing that in the shell running in some terminal emulator window. So, when Vim starts, its stdin and stdout are connected to a real terminal, and Vim checks that.

    In your code, you actually add a layer between the terminal and Vim, and so Vim is no longer connected to a terminal, but rather to a plain data stream.
    A little demonstration using bash in a GNOME Termnial running on Linux:

    $ stat -L -c %F /dev/fd/0
    character special file
    
    $ stat -L -c %F /dev/tty
    character special file
    
    $ echo test | stat -L -c %F /dev/fd/0
    fifo
    
    $ stat -L -c %F /dev/fd/0 <<EOF
    > test
    > EOF
    fifo
    
    $ f=$(mktemp); stat -L -c %F /dev/fd/0 <"$f"; rm "$f"
    regular empty file
    

    Observe that in the first two cases we asked stat running right in the shell to figure out the type of the file its stdin is connected to, and it's a "character special" file — a terminal, in this case. When we ask it to do the same when its stdin is connected to a pipe (the "here document" in the penultimate example is handled by bash in the same way: it's presented to the tagret program as a pipe), it reports the type of the file is fifo. Ultimately, we connect its stdin to a real file, and it figures that out, too.

    In your code, Vim's stdin will be connected to pipe (fifo), and it won't be able to work with it properly as it's not a terminal.

    The solution

    You need to run Vim on a terminal. To achieve that, you should employ the so-called pseudo terminal machinery (exploited, among others, by SSH and terminal multiplexors such as screen and tmux): basically, you create a PTY for Vim, make it run on that PTY and then interact with that subsystem.
    The go-to Go package implementing PTY support is probably this but see this in general.

    Also see this.