DEV Community

Cover image for Reading readline
Kostya Malinovskiy
Kostya Malinovskiy

Posted on

Reading readline

CodeCrafters verifies each challenge step by running test suit on their side on each push to the main branch. The problem with that is that for free membership tests are not always run instantly and can end up waiting in queue. In addition, even if test runs start right away, they still take considerable amount of time. After I started working on the Shell challenge, I quickly realized that I need to have local test suit. I found that Golang provides testing infrastructure right out of the box, so started building on top of it.

For go test, you need to implement at least one function with name Test - example. In my case I wanted to have an integration tests, so I needed some additional setup around. Go testing package allows you to do so by implementing TestMain function, that will run once per suite run.

func TestMain(m *testing.M) {
    err := exec.Command("go", "build", "-o", "testapp").Run()
    if err != nil {
        fmt.Fprintf(os.Stderr, "failed to build app: %v\n", err)
        os.Exit(1)
    }

    exitCode := m.Run()
    os.Remove("testapp")

    os.Exit(exitCode)
}
Enter fullscreen mode Exit fullscreen mode

Here I build my shell app, run the suite and remove the app afterwards.

Then in a test function I have to run the shell, get stdin, stdout and stderr handlers to be able to send commands to my shell and receive and verify output. I’ll omit these details as it is not the subject of this blog.

Initially I implemented shell as a for loop that runs indefinitely, print prompt, read user input and print the output like this:

for {
  fmt.Print("$ ")
  input, _ := rl.Readline()
  out, _ := executeCmd(input)
  fmt.Println(out)
}
Enter fullscreen mode Exit fullscreen mode

Then in test setup I do:

cmd := exec.Command("./testapp") // create cmd Struct for my shell
stdout, err = cmd.StdoutPipe() // connect to the shell stdout pipe
stdoutReader := bufio.NewReader(stdout) // wrap stdout pipe with the buffered reader, to get ReadString(), ReadByte() methods
Enter fullscreen mode Exit fullscreen mode


Then in tests I used bufio.ReadString('$') to get the tested command output, as I knew that $ means that the new prompt is there and all output has already been printed. Of course I would be in trouble if a tested command output would include $ somewhere in the middle, but as I control command output and test data, this thing was working for me.

It was fine until I reached the step where in the challenge description it was recommended to use readline library. This is when my tests started to fail, It appeared that readline doesn’t print prompt when enclosing app is not used through TTY.

My tests were hanging as ReadString was blocking execution because it was waiting for $ to appear in the shell stdout, which was not happening.

After several hours of trial and error I came up with idea to read output by byte in a goroutine, send each byte through a channel to the calling function. In its turn the calling function would return whatever it had collected to the upstream code on a timeout. But I had to be sure that the goroutine finished before next shell command is tested, otherwise there is a risk that it will start collecting input from the next command and stealing it from the proper reader groutine. To solve this I decided to sent some special character to the shell stdin so it would appear in the stdout, and groutine could read it and know that is has to exit. At first I tried to send \x04 which means EOT(end of transmission), and felt semantically correct. By doing so I learned 2 things:
1. I needed to use echo so something could get to stdout
2. \x04 was read by readline(or is by something else), and was interpreted as signal to finish execution.

Instead I went with echoing %, and It worked for me. Again if a command output includes % this would cause me troubles, but I’m ok with such a tradeoff while I’m in control of test data. When it will not be the case I’ll figure out better solution.

Here’s how function to get a command output looks:

func (c ShellTestContext) ReadUntilPrompt() (string, error) {
    var result strings.Builder

    t := c.T
    reader := c.StdoutReader
    buf := make(chan byte, 1)
    done := make(chan struct{})
    c.log("About to start goroutine")
    go func(){
        c.log("Goroutine started")
        var b byte
        var err error
        defer close(done)
        for {
            b, _ = reader.ReadByte()
            if err != nil {
                c.log(fmt.Sprintf("Inside goroutine error: %v", err))
                t.Fatal(err)
                close(buf)
                break
            }
            if b == byte('%') {
                c.log("Inside goroutine closing the channel")
                reader.ReadByte() // read \n after %
                close(buf)
                break
            }else{
                buf <- b
            }
        }
    }()

    for {
        select {
        case anotherByte := <- buf:
            result.WriteByte(anotherByte)
        case <- time.After(220 * time.Millisecond):
            c.log(fmt.Sprintf("Timeout goroutine, received result: %v", result.String()))
            c.Stdin.Write([]byte("echo %\n")) // use % symbol to signal goroutine to stop reading and finish
            <- done
            return result.String(), nil
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

P.S.
While putting these post together I learned that technick with looking for specific character is called Sentinel. Also I learned that TTY or not can be determinded to by inspectint file descriptor that is used for STDIN, STDOUT or STDERR. It can be done via syscall or via https://pkg.go.dev/golang.org/x/term#IsTerminal.

Top comments (0)