返回

修复Go中Bash Readline输出被stderr(FD2)“吃掉”问题

Linux

别让 Readline "吃掉" 你的 Bash 输出:解决 Go 程序中输出流重定向到 FD2 的怪圈

写 Go 程序包装(wrap)并执行一个交互式 bash shell 时,你可能会想搞点“小动作”,比如,劫持子进程的标准错误输出(stderr,文件符 2,简称 FD2),用你自己的东西替换掉,方便从 Go 程序里塞点信息进去。这想法不错,实现起来也基本没啥大问题。bash 也能跑起来。

但坑爹的地方来了:bash 里自带的 readline 库(就是让你能编辑命令行、用历史记录那个好东西)死活不愿意用标准输出(stdout,FD1)作为它的输出流。结果就是,你塞到 FD2 里的东西,反而把 readline 的输出给“吃”了——你在键盘上敲的东西显示不出来(别想歪了,跟终端的回显模式没关系),连带你的提示符(尤其是多行复杂提示符,比如 PS1 设置了两行的)最后一行也没了。很明显,锅是 readline 的。

可按理说,bash 加上 readline,只应该在检测到 stdout 不是一个终端(TTY)的情况下,才会考虑用 FD2 做输出流啊(如果我理解错了,请大佬指正)。

问题现场复现

先贴一段还能正常跑的 Go 代码,展示一下基本操作,但还没加上劫持 FD2 的部分。这段代码就是简单地执行 bash,效果杠杠的。

package main

import (
	"os"
	"os/exec"
	"syscall"
    // 假设你有这个包或者类似功能的包
    // "your_project/config"
    // "your_project/service_util"
)

// 假设的配置结构体
type Config struct {
    Args []string
    // 其他配置...
}

// 假设的辅助函数
func buildScriptCommand(scriptPath string, cfg *Config) string {
    // 这里构建实际要在 bash -c "..." 中执行的命令字符串
    // 比如 source scriptPath; maybe_start_interactive_session "$@"
    // 注意:这里只是示例,实际内容需要根据你的需求定
    baseCmd := "echo 'Bash starting...'; exec bash -i" // 示例:执行一个交互式 bash
    if scriptPath != "" {
        // 可能需要 source 一个脚本
        // baseCmd = fmt.Sprintf("source %s; %s", scriptPath, baseCmd)
    }
    // 这里仅作演示,简单返回一个交互式 bash 命令
    return baseCmd
}

// 假设的打开 socket 函数
func OpenSocket(name string) (*os.File, error) {
    // 这里是模拟,实际需要实现 socket 打开逻辑
    // 并且返回的文件符需要能在 ExtraFiles 中传递
    // 为了演示,我们创建一个简单的 pipe
    r, w, err := os.Pipe()
    if err != nil {
        return nil, err
    }
    go func() {
        // 模拟向 socket 写消息
        // w.Write([]byte("Hello from msgSock\n"))
        // 保持打开,模拟 socket
    }()
    // 返回写端,模拟子进程可以写的 socket FD
    // 注意:实际场景下,你需要管理这个 pipe 或者 socket 的生命周期
    // 而且子进程需要知道用哪个 FD 来读取/写入
    // defer r.Close() // 读端可能在主进程中使用
    return w, nil
}


func executeScript(scriptPath string, cfg *Config) error {
    // 模拟打开一个用于消息传递的 socket 文件描述符
    // 注意:实际实现可能不同,这里仅为演示目的
    // 并且,传递给子进程的文件描述符编号从 3 开始
    var msgSock *os.File
    var err error
    // 注释掉实际的 socket 打开,防止编译错误
    // msgSock, err = OpenSocket("messages")
    // if err != nil {
    //  return fmt.Errorf("failed to open message socket: %w", err)
    // }
    // defer msgSock.Close() // 确保在函数退出时关闭

    // /usr/bin/env bash -c "..." 结构更灵活
    cmd := exec.Command(
        "/usr/bin/env",
        "bash",
        "-c",
        buildScriptCommand(scriptPath, cfg), // 假设这个函数返回需要在bash -c里执行的命令串
    )

    // 传递额外的文件描述符
    // 文件描述符从 3 开始。FD 0, 1, 2 是标准输入、输出、错误。
    // cmd.ExtraFiles = append(cmd.ExtraFiles, msgSock) // 把 msgSock 作为 FD 3 传进去


    // 尝试获取并设置控制终端 TTY
    // 这是关键一步,让子进程认为它连接到了一个真实的终端
    tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
    if err == nil {
        defer tty.Close()
        cmd.Stdin = tty    // 标准输入连接到 TTY
        cmd.Stdout = tty   // 标准输出连接到 TTY
        // Stderr 保持默认或显式设置
        // 如果不设置 cmd.Stderr,它默认继承父进程的 stderr
        // cmd.Stderr = os.Stderr // 可以显式设置回父进程的 stderr

        // 设置进程组、控制终端等,让 bash 认为自己是前台交互进程
        cmd.SysProcAttr = &syscall.SysProcAttr{
            Setpgid:    true,    // 创建新的进程组
            Ctty:       int(tty.Fd()), // 设置控制终端
            Foreground: true,    // (这个字段在 Linux SysProcAttr 里可能没有直接对应,
                                 // Setpgid 和 Ctty 通常足够。主要目的是确保是前台进程组)
        }
    } else {
        // 如果无法打开 /dev/tty (比如非 TTY 环境下运行)
        // 采取备用方案,比如使用 os.Stdin/Stdout/Stderr
        // 但交互式 Readline 可能行为不符合预期
        cmd.Stdin = os.Stdin
        cmd.Stdout = os.Stdout
        cmd.Stderr = os.Stderr // 默认继承
    }


    // 附加额外的命令行参数给 bash -c 之后的脚本(如果 buildScriptCommand 设计为接受参数)
    // 这取决于 buildScriptCommand 的实现
    // if len(cfg.Args) > 0 { // 注意这里修改了索引判断
    //     cmd.Args = append(cmd.Args, "--") // bash -c "script" -- arg1 arg2 ...
    //     cmd.Args = append(cmd.Args, cfg.Args...)
    // }

    // 注意:下面的操作会导致 Readline 输出到 FD2
    // 1. 直接将 stderr 指向 msgSock
    // cmd.Stderr = msgSock // <-- Readline 输出会跑到这里来

    // 2. 保持 stderr 指向 TTY (或 os.Stderr), 但在 bash 内部重定向
    //    这种方式也可能导致 Readline 使用错误的流,需要在 bash 脚本内部小心处理 FD

    return cmd.Run()
}

// 模拟主函数调用
// func main() {
//  cfg := &Config{Args: os.Args[1:]} // 简单示例
//  err := executeScript("path/to/your/script.sh", cfg)
//  if err != nil {
//      fmt.Fprintf(os.Stderr, "Error executing script: %v\n", err)
//      os.Exit(1)
//  }
// }

这段代码跑起来没毛病。但只要我一尝试把 cmd.Stderr 设置成我的 msgSock,或者甚至尝试在 bash 的初始化文件里,把 FD1 和 FD2 对调一下,同时把另一个文件描述符挪到 FD2 的位置,结果都一样:readline 把输出怼到了 FD2 上,结果就被我自定义的 FD2 处理逻辑给吞掉了。我就是想让 readline 老老实实用 FD1/STDOUT 输出啊!

即使用代码直接把 TTY (/dev/tty) 同时赋给 cmd.Stdincmd.Stdout,行为也还是一样。我知道用 PTY (Pseudo-Terminal,伪终端) 是处理这种场景的标准姿势,但我实在想尽量减少外部依赖。

我试过的其他花招包括(列出来免得大家重复建议):

  • 换着花样执行 bashbash --noediting 禁用 readline 后,输入的东西就能正常回显了,这更加证明了是 readline 在捣鬼。但我需要 readline 的交互功能啊!也试过非交互模式,效果类似。甚至把命令精简到只剩 bash,不带任何额外的脚本参数,怎么切分这个“问题派”,结果都是吃 X。
  • 用 PTY: 前面提到了,试过 Go 的事实标准库 creack/pty,结果 readline 的行为还是老样子。我知道直接访问 /dev/tty 有点糙,但目前看,用 PTY 也没解决根本问题。当然,如果你知道用 creack/pty 有啥特殊咒语能搞定这事,我洗耳恭听!
  • 直接把 msgSock 喂给 FD2: 也试过了,把 msgSock 直接设为 cmd.Stderr。这也是为啥我确定是 readline 自己往 FD2 写的证据之一——我在 Go 代码里能收到提示符的最后一行以及我敲进去的字符,我都打印出来验证过了。
  • msgSock 放到一个高位 FD,然后在 bash 里用 exec 重新安排: 比如把 msgSock 放到 FD 31337,然后在 bash 里执行 exec 2>&1 1>&{original_stdout_copy} 类似的操作,试图把原来的 stdout 挪回来给 readline 用,同时把 stderr 指向别处。然并卵。
  • 病急乱投医,改 .inputrc 设置: 纯属瞎蒙,在 .inputrc 文件里加了些不着边际的设置,想看看能不能影响 readline 的输出流选择。毫无悬念,没用。

刨根问底:Readline 为啥非要跟 FD2 过不去?

核心原因藏在 bash 如何初始化 readline 上。当你启动一个交互式 bash shell 时,它会进行一系列检查来设置 readline。关键的一环是:

Bash 会检查它的标准错误(FD2)是否连接到了一个终端(TTY)。

  • 如果 stderr 是一个 TTY: bash 就会告诉 readline 使用 stderr 作为其输出流 (rl_outstream = stderr)。这是基于一个假设:交互式会话中,错误信息和命令提示、用户输入通常都在同一个终端设备上显示。
  • 如果 stderr 不是一个 TTY(比如被重定向到了文件或管道): bash 会退而求其次,检查 stdout (FD1) 是否是 TTY。如果是,理论上 readline 应该使用 stdout (rl_outstream = stdout)。

那么问题出在哪?

在你的 Go 程序里,当你试图用 msgSock (一个 socket 或 pipe) 替换掉 bashstderr 时,bash 在启动时检测到 FD2 不是 一个 TTY。这没问题。它接着检查 FD1 (stdout)。如果你正确地将 cmd.Stdout 设置为了 /dev/tty 或者 PTY 的 TTY 端,那么 FD1 一个 TTY。

但是readline (或者 bash 设置 readline 的逻辑) 的行为似乎比文档描述的还要“固执”一点,或者说,在某些场景下表现不一致。有几种可能:

  1. 启动时 FD 状态的“快照”效应 :即使你后来在 bash 内部用 exec 命令调整了文件描述符,readline 可能在启动早期就已经根据当时的 FD 状态决定了它的输出流,并且不再改变。
  2. 控制终端 (Ctty) 的影响 :虽然你设置了 cmd.SysProcAttr.Ctty,但 stderr 的非 TTY 状态可能仍然优先影响了 readline 对输出流的选择决策,或者干扰了 bash 正确地将控制终端信息传递给 readline
  3. Readline 自身的健壮性问题或特定版本行为 :不排除特定版本的 readlinebash 在处理这种边缘情况时存在 bug 或非预期的行为。

关键在于,你的操作(替换 FD2)正好触发了那个让 bash/readline 觉得应该用 stderr(即使它现在不是 TTY 了,或者选择逻辑在某些条件下仍然倾向于使用原始的 FD2 指向的目标,如果那个目标能被某种方式识别为“终端相关”)或者在 FD1/FD2 判断逻辑上走了“弯路”的条件。

对症下药:强制 Readline 回归 FD1

明白了问题根源,解决起来就有方向了。主要有两种思路:一是拥抱标准,使用 PTY;二是巧妙地“欺骗” bash,让它在初始化 readline 时做出正确的选择。

方案一:拥抱 PTY (推荐,最标准可靠)

虽然你提到尝试过 creack/pty 但没成功,但 PTY 仍然是解决此类问题的正道 。很可能之前的尝试方式不对。PTY 的核心作用就是模拟一个真实的终端,它提供一对文件描述符:主控端(master)和从属端(slave)。你的 Go 程序操作主控端,子进程(bash)连接到从属端。对于子进程来说,从属端就是一个完整的终端,它的 stdin, stdout, stderr 都默认连接到这个模拟终端。

原理与作用:

使用 PTY 时,bash 启动时会看到它的 stdin, stdout, 和 stderr 都连接到了 PTY 的从属端(slave),这完全符合一个标准 TTY 的特征。因此,bash 会正常初始化 readline,并且 readline 会默认将输出(包括提示符和用户输入的回显)发送到它的 stdout (也就是 PTY 从属端)。

操作步骤 (使用 creack/pty):

  1. 引入依赖: go get github.com/creack/pty
  2. 修改 Go 代码:
package main

import (
	"fmt"
	"io"
	"os"
	"os/exec"
	"syscall" // 仍然需要,可能用于设置 Winsize

	"github.com/creack/pty"
    // ... 其他 import ...
    // 假设的配置和辅助函数同上
)


func executeScriptWithPty(scriptPath string, cfg *Config) error {
	// 构建 bash 命令
	bashCmd := buildScriptCommand(scriptPath, cfg) // 比如 "exec bash -i"
	cmd := exec.Command("/usr/bin/env", "bash", "-c", bashCmd)

	// --- 使用 PTY ---
	// Start an interactive command with a PTY
    // ptmx 是主控端 (master), tty 是从属端的 TTY 文件名 (供参考, 主要操作 ptmx)
	ptmx, err := pty.Start(cmd)
	if err != nil {
		return fmt.Errorf("failed to start pty: %w", err)
	}
	// 确保 PTY master 在结束后关闭
	defer func() { _ = ptmx.Close() }()

    // --- 处理窗口大小变化 ---
    // 需要一个 goroutine 监听 SIGWINCH 信号,并更新 PTY 的大小
    // 这里省略具体实现,但它是 PTY 正确工作的关键部分
    // 参考 creack/pty 的示例代码进行处理

    // --- 输入输出处理 ---
    // 将你的 Go 程序的标准输入复制到 PTY 主控端
    // 这会将键盘输入传递给 bash
	go func() { _, _ = io.Copy(ptmx, os.Stdin) }()

    // !!! 关键点: 处理来自 PTY 的混合输出 !!!
    // ptmx 会包含 bash 的 stdout 和 stderr 的 *所有* 输出
    // 你需要在这里读取 ptmx 的输出,然后决定哪些显示给用户,哪些送到你的 msgSock
	// 这里是一个简单的例子:直接全部打到 Go 程序的标准输出
    // _, _ = io.Copy(os.Stdout, ptmx)

    // 更精细的控制:读取并分发
    // 创建一个管道,用于模拟 msgSock 的写入端
    // (实际应用中,这里应该使用你真正的 msgSock)
    pipeReader, pipeWriter, err := os.Pipe()
    if err != nil {
        return fmt.Errorf("failed to create pipe for stderr simulation: %w", err)
    }
    defer pipeReader.Close()
    defer pipeWriter.Close()

    // 启动一个 goroutine 处理你的 "msgSock" 逻辑
    go func(reader io.Reader) {
        // 在这里读取模拟的 stderr 输出,并执行你的逻辑
        buf := make([]byte, 1024)
        for {
            n, err := reader.Read(buf)
            if n > 0 {
                fmt.Printf("[MSG_SOCK_HANDLER] Received: %s", string(buf[:n]))
                // 这里可以发送到你的真实 socket 或进行其他处理
            }
            if err == io.EOF {
                fmt.Println("[MSG_SOCK_HANDLER] Pipe closed.")
                break
            }
            if err != nil {
                fmt.Fprintf(os.Stderr, "[MSG_SOCK_HANDLER] Error reading pipe: %v\n", err)
                break
            }
        }
    }(pipeReader) // 把管道的读端传给处理器


    // 读取 PTY 输出,同时写到 Go 的 stdout 和模拟的 stderr (pipeWriter)
    // 使用 MultiWriter 将 ptmx 的输出同时导向两个地方
    // 注意:这种方式无法完美区分 stdout 和 stderr,所有输出都会被复制
    // 如果你需要严格区分,事情会更复杂,可能需要分析输出来判断来源
    // 或者采用下面的方案二。
    outputDest := io.MultiWriter(os.Stdout, pipeWriter)
    _, err = io.Copy(outputDest, ptmx)
    if err != nil {
        // 忽略 EIO 错误,这通常表示 PTY 关闭
        if err.Error() != "read/write on closed pty" && err != io.EOF {
            // Log or print other errors if necessary
            fmt.Fprintf(os.Stderr, "Error copying from pty: %v\n", err)
        }
    }


	// --- 等待命令结束 ---
	// 注意:pty.Start 已经启动了命令,这里等待它完成
	// 等待命令退出
    // 等待命令执行完毕
    if err := cmd.Wait(); err != nil {
        // 注意:根据 bash 退出码判断是否是预期错误
        // 例如,用户正常 exit shell 也会有退出码
        // fmt.Fprintf(os.Stderr, "Bash command exited with error: %v\n", err)
        // return fmt.Errorf("bash command failed: %w", err)
    }


	fmt.Println("Bash session finished.")
	return nil
}

关键点解读与进阶:

  • pty.Start(cmd) 会帮你搞定一切:创建 PTY,设置子进程的 stdin, stdout, stderr 全部连接到 PTY 从属端,设置控制终端 (Ctty) 等。你不需要再手动设置 cmd.Stdin, cmd.Stdout, cmd.StderrSysProcAttr
  • 统一处理输出流: 你的 Go 程序现在只需要从 PTY 主控端 (ptmx) 读取数据。这个数据流是 bashstdoutstderr 的混合体。你需要在 Go 代码中处理这个流:
    • 大部分情况下,你可以直接将 ptmx 的输出拷贝到 Go 程序的 os.Stdout,这样用户就能看到 bash 的正常输出和交互。
    • 劫持 stderr 的需求 :既然无法在进程层面分离 stderr,你需要在 Go 读取 ptmx 后,在 Go 代码层面 进行处理。你可以:
      • 简单粗暴:用 io.MultiWriterptmx 的输出同时写到 os.Stdout 和你的 msgSock。缺点是 msgSock 会收到所有输出,包括 stdout 的。
      • 智能过滤:读取 ptmx 输出,通过模式匹配 (比如检查是否像错误信息)或者其他启发式方法,判断哪些内容“看起来像”是 stderr 的,然后只把这部分转发到 msgSock。这不完美,但可能是最接近你原始意图的方式。
  • 窗口大小同步(SIGWINCH): 完整的 PTY 实现需要监听终端窗口大小变化信号 (SIGWINCH),并使用 pty.Setsize 更新 PTY 的大小,否则 bash 和其他命令行程序(如 vim)可能显示不正常。这个在 creack/pty 的文档和例子中有说明,对于交互式应用非常重要。

方案二:启动时“欺骗” Bash,然后在内部重定向

如果你实在不想用 PTY,或者对分离 stderr 有强烈的执念,可以尝试在 bash 启动和内部执行流程上做文章。思路是:确保 bash 启动时,FD1 是 TTY 而 FD2 不是,这样 readline 应该会选择 FD1。然后在 bash 启动之后 ,再偷偷把 FD2 重定向到你的 msgSock

原理与作用:

利用 bash 启动时对 FD 的检查机制。先让 bash 在一个“理想”环境下(FD1=TTY, FD2!=TTY)完成 readline 的初始化,绑定到 FD1。之后,在 bash 已经运行起来的交互会话中,使用 bash 自身的 exec 命令来操纵文件描述符,将 FD2 重定向到你想要的目标(msgSock 对应的 FD)。

操作步骤:

  1. Go 代码调整:

    • 仍然需要设置 TTY 或 PTY 作为 cmd.Stdincmd.Stdout
    • cmd.Stderr 设置为一个非 TTY 的目标,比如 os.DevNull 或一个临时管道的写入端(之后可以关闭)。不要 指向 /dev/ttyos.Stderr (如果它本身是 TTY)。
    • 将你的 msgSock 文件描述符通过 cmd.ExtraFiles 传递给子进程,假设它在子进程中是 FD 3。
    package main
    
    import (
    	"fmt"
    	"os"
    	"os/exec"
    	"syscall"
        "strconv" // 用于 FD 编号转字符串
        // ... 其他 import ...
    )
    
    // 假设 Config, OpenSocket, executeScript 框架如前
    
    // 关键在于修改 buildScriptCommand
    func buildScriptCommandForRedirect(scriptPath string, cfg *Config, msgSockFd int) string {
        // 假设 msgSock 在子进程中的 FD 是 msgSockFd
    
        // 基础命令,确保 bash 是交互式
        // bash -i 会读取 .bashrc, 这可能是执行重定向的好地方
        // 或者直接在 -c 的命令里执行
        bash_setup_commands := fmt.Sprintf(`
            # Bash 启动时,stdout 是 TTY,stderr 是 /dev/null (或别的非 TTY)
            # Readline 此时应该绑定到 stdout (FD 1)
    
            # 现在,将真正的 stderr (FD 2) 重定向到 msgSock 所在的 FD
            exec 2>&%d
            # 关闭临时的 msgSock FD 副本(可选,良好实践)
            exec %d>&-
    
            # 现在 FD 1 -> TTY (for Readline), FD 2 -> msgSock
            # 可以 source 用户的脚本或者直接启动最终的交互 shell/命令
            echo "Bash ready. Stderr redirected to FD %d. Starting interactive shell..."
            # 如果需要 source 脚本
            # if [ -f "%s" ]; then source "%s"; fi
    
            # 确保我们仍然在一个交互式 shell 中
            # 如果原始 bash -c 调用已是 -i,可能不需要再次 exec bash -i
            # 但如果不是,或者想确保环境干净,可以加 exec bash -i
             exec bash -i "$@"
    
        `, msgSockFd, msgSockFd, msgSockFd /*, scriptPath, scriptPath */) // 注意处理 scriptPath 注入
    
        // 将参数传递给最终的 exec bash -i
        // 这里我们把 cfg.Args 传给最后的 bash -i
        // command := []string{"bash", "-c", bash_setup_commands, "bash"} // "bash" 是 $0
        // if len(cfg.Args) > 0 {
        //    command = append(command, cfg.Args...)
        // }
        // 返回的是要在外层 bash -c "..." 中执行的内容
        return bash_setup_commands
    }
    
    
    func executeScriptWithInternalRedirect(scriptPath string, cfg *Config) error {
        msgSock, err := OpenSocket("messages") // 你的 socket 实现
        if err != nil {
            return fmt.Errorf("failed to open message socket: %w", err)
        }
        defer msgSock.Close()
    
        // 获取 msgSock 的 FD
        msgSockFd := int(msgSock.Fd())
    
        // 构造传递给子进程的 FD 列表。msgSock 是第一个额外文件,所以是 FD 3。
        // 注意:exec.Command 会自动处理 FD 映射
        targetFdInChild := 3 // 子进程中代表 msgSock 的 FD
    
        cmd := exec.Command(
            "/usr/bin/env",
            "bash", // 注意这里不是直接 bash -c 了,我们可能需要在下面设置参数
        )
    
        // 设置要在 bash -c 中执行的脚本内容
        cmd.Args = append(cmd.Args, "-c", buildScriptCommandForRedirect(scriptPath, cfg, targetFdInChild))
        // 设置传递给内层 bash -i 的 $0 (通常是命令名)
        cmd.Args = append(cmd.Args, "bash") // 这个 "bash" 会成为内层脚本的 $0
        // 附加其他参数给内层脚本(如果需要的话)
        // if len(cfg.Args) > 0 {
        //     cmd.Args = append(cmd.Args, cfg.Args...)
        // }
    
        // 传递 msgSock 文件描述符 (它将成为子进程的 FD 3)
        cmd.ExtraFiles = []*os.File{msgSock}
    
        // 设置 TTY (标准做法)
        tty, err := os.OpenFile("/dev/tty", os.O_RDWR, 0)
        if err == nil {
            defer tty.Close()
            cmd.Stdin = tty    // FD 0 -> TTY
            cmd.Stdout = tty   // FD 1 -> TTY
    
            // !!! 关键:将 Stderr 指向非 TTY !!!
            devNull, err := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
            if err != nil {
                return fmt.Errorf("failed to open /dev/null: %w", err)
            }
            defer devNull.Close()
            cmd.Stderr = devNull // FD 2 -> /dev/null
    
            cmd.SysProcAttr = &syscall.SysProcAttr{
                Setpgid: true,
                Ctty:    int(tty.Fd()),
            }
        } else {
            // 非 TTY 环境备用方案,可能效果不好
            cmd.Stdin = os.Stdin
            cmd.Stdout = os.Stdout
            // 即使在这种情况下,也要确保 Stderr 不是 TTY
             devNull, e := os.OpenFile(os.DevNull, os.O_WRONLY, 0)
             if e != nil {
                 return fmt.Errorf("failed to open /dev/null: %w", e)
             }
             defer devNull.Close()
             cmd.Stderr = devNull // FD 2 -> /dev/null
        }
    
        return cmd.Run()
    }
    
    
  2. Bash 脚本 (buildScriptCommandForRedirect 返回的内容):

    • 这个脚本由 bash -c 执行。
    • 它首先执行 exec 2>&3 (假设 msgSock 传入后是 FD 3)。这将把当前 shell (以及它后续启动的子进程,除非它们自己重定向) 的标准错误重定向到 FD 3 (也就是你的 msgSock)。
    • 可选地,用 exec 3>&- 关闭原始的 FD 3,因为它已经被复制到 FD 2 了。
    • 最后,使用 exec bash -i "$@" 来启动一个新的 、干净的、交互式的 bash shell。这个新的 bash 会继承之前设置好的文件描述符(FD 0 和 FD 1 连接到 TTY,FD 2 连接到 msgSock)。因为这个最终的 bash -i 启动时环境已经设置好,readline 应该能正常工作在 FD 1 上。

安全与进阶:

  • 脆弱性: 这种方法依赖于 bashreadline 的内部初始化行为,可能在不同版本或不同系统上表现不一。它不如 PTY 方案健壮。
  • FD 编号: 需要精确管理传递给子进程的文件描述符编号 (ExtraFiles) 和在 bash 脚本中使用的编号。ExtraFiles[0] 在子进程中是 FD 3,ExtraFiles[1] 是 FD 4,以此类推。
  • 错误处理: bash -c 内部的 exec 命令如果失败,可能难以捕捉错误。需要仔细设计脚本。
  • 交互性: 确保最终执行的是一个真正的交互式 bash (-i),并且 TTY/PTY 设置正确,否则 readline 依然可能无法正常启用。

方案三:修改 Bash/Readline 源码 (理论选项,极不推荐)

理论上,你可以直接修改 bashreadline 的源代码,改变它们选择输出流的逻辑。但这会带来巨大的维护负担,需要为每个目标平台和 bash/readline 版本维护补丁,绝对是下下策。

小结

与交互式子进程打交道,特别是涉及到文件描述符重定向时,很容易踩坑。

  • 使用 PTY (伪终端) 是处理交互式子程序(如 bash shell)的标准、健壮方式。它能正确模拟终端环境,让 readline 正常工作。你需要适应在 Go 父进程中处理混合了 stdoutstderr 的输出流。
  • 如果你非要直接控制 bashstderr 文件描述符,可以尝试在启动 bash 时先“骗”过 readline 的初始化检查(确保 FD1 是 TTY,FD2 不是),然后在 bash 内部再使用 exec 命令重定向 stderr 。这种方法更“黑科技”,可能不稳定,但更接近你最初分离 FD 的想法。

根据你的具体需求和对稳定性的要求,选择合适的方案吧。对于需要长期维护和跨平台兼容性的项目,强烈推荐 PTY。