1
votes

If I want to use Golang's os/exec functionality to run bash in a subcommand, I can do this:

package main

import (
    "os"
    "os/exec"
)

func main() {
    subcommand := exec.Command("bash")
    subcommand.Stderr = os.Stderr
    subcommand.Stdout = os.Stdout

    subcommand.Stdin = os.Stdin
    err := subcommand.Run()
    if err != nil {
        panic(err)
    }
}

This works perfectly. My test case is:

  • Type echo Hello and press enter
  • Press the up arrow on my keyboard and press enter

This results in:

myname@mycomputer:~/Desktop/thing$ echo Hello
Hello
myname@mycomputer:~/Desktop/thing$ echo Hello
Hello

However, my specific use case involves multiple sources writing to the subcommand's stdin. This requires a more complicated input than just connecting the subcommand.Stdin to os.Stdin; my research led me to io.MultiReader, which seems to meet my needs. The first step in accomplishing my use case would seem to be using a stream for the subcommand's stdin, like this:

package main

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

func main() {
    subcommand := exec.Command("bash")
    subcommand.Stderr = os.Stderr
    subcommand.Stdout = os.Stdout

    pipeWrite, err := subcommand.StdinPipe()
    if err != nil {
        panic(err)
    }
    go func() {
        // Copy os.Stdin to the subcommand's stdin pipe.
        // Because this is a blocking call, I've put it in a goroutine.
        _, err = io.Copy(pipeWrite, os.Stdin)
        if err != nil {
            panic(err)
        }
    }()
    err = subcommand.Run()
    if err != nil {
        panic(err)
    }
}

Running the same test case above results in this:

echo Hello
Hello
^[[A
bash: line 2: $'\E[A': command not found

As you can see, there is no prompt this time, and pressing the up arrow results in the escape sequence for the up arrow (I'm not sure if "escape sequence" is correct terminology) instead of going back to my previous command.

Clearly I'm doing something wrong here, but I don't know what it could be. I've tested a lot of things here, but including them all would make this question far too long. Some of the most important thoughts/ideas I've had are:

  • The documentation mentions different handling for io.Reader versus a file, but that shouldn't matter here, since the pipe is still a file
  • I tried looking into whether there was some way the subcommand decides whether it is interactive, like how docker exec has -it flags to specify that, but I couldn't find an equivalent
  • I tried creating my own pipe with os.Pipe instead of using subcommand.StdinPipe, but there were no differences
  • I tried taking a step back and using io.Copy from a separate stream and copying it to os.Stdin (since that's my required use case), but that just resulted in the separate stream's contents printing to the terminal but not being detected by the subprocess for some reason
  • I wondered whether os.Stdin having a file descriptor of 0 could be important, but I couldn't think of a good way to replicate that behavior in a way that wouldn't require me to then use io.Copy to copy os.Stdin to that new stdin-ish file, which as mentioned in the previous bullet didn't work
  • It could be due to os.Stdin being a character special file (ModeDevice and ModeCharDevice), while pipeWrite is just a named pipe file (ModeNamedPipe), but trying to fix that has the same problem as the previous bullet

I am running Ubuntu 21.10

In short: bash—as most such programs do—checks at startup whether it is connected to a terminal (or a terminal emulator, which the X Window application in which you're executing bash, is) and if so, enables its interactive features. In order to communicate with such applications in their interactive mode, you have to use a PTY. There exist 3rd-party packages for Go which implement such support.kostix
I'd also highly recommend to read this classic essay on the topic of TTYs to understand how this stuff works on Unix-like systems.kostix