自调用可执行文件
摘要
本文介绍了自调用可执行文件的概念,即程序启动自身的另一个实例,并演示了其在 Go 测试(在子进程中运行 main 函数)和 TUI 工具(例如 jjui 使用 SSH_ASKPASS 通过子进程提示输入密码)中的应用。
<p><a href="https://lobste.rs/s/o78n3y/self_calling_executables">评论</a></p>
查看缓存全文
缓存时间: 2026/06/02 17:36
# 自调用可执行文件 | Olivier's 日志
来源:https://log.pfad.fr/2026/self-calling-executable/
2026年6月2日,于
我将“自调用可执行文件”称为一种 Inception 技术,即当前运行的可执行文件(直接或间接)启动自身另一个版本的过程。这种技术在测试场景以及命令行工具(如 TUI)中非常有用。
## 测试可执行文件的 main 函数
理想情况下,可执行文件的结构应正确设计,以便进行隔离测试。然而,很容易(也很常见)出现不够优化的架构(例如依赖全局状态),这会在多个测试并行(甚至串行)运行时造成干扰。
在 Go 中,我们可以通过启动子进程来规避此问题。这个子进程就是当前正在运行的测试可执行文件本身,但会设置一个特定的环境变量(例如 `TEST_MAIN`)。
在 `TestMain(m *testing.M)`(一个特殊的 Go 测试函数)中,如果检测到该环境变量,则不运行标准测试,而是直接执行主命令。
``
func TestMain(m *testing.M) {
switch os.Getenv("TEST_MAIN") {
case "":
os.Exit(m.Run()) // 标准测试
default:
os.Exit(cmd.Main()) // 运行你的 main 函数
}
}
// runMain 可在测试内调用,用以黑盒方式运行可执行文件。
func runMain(args ...string) ([]byte, error) {
// os.Args[0] 指向当前可执行文件
cmd := exec.Command(os.Args[0], args...)
cmd.Env = append(os.Environ(),
"TEST_MAIN=main",
)
return cmd.Output()
}
``
关于该技术的其他变体,以下博客文章值得一读:
用模拟程序测试 Go 中的 os/exec \| Abhinav Gupta (https://abhinavg.net/2022/05/15/hijack-testmain/)
## 与主进程交互
自调用可执行文件的另一个用途是与第三方工具交互。例如,ssh 命令会读取 `SSH_ASKPASS` 环境变量,以定位一个可执行文件,用于在需要提示用户输入密码(如 SSH 密钥密码或 TPM PIN)时运行。
当 TUI 需要执行 SSH 操作时,它可以将 `SSH_ASKPASS` 环境变量指向自身,并将 ssh 作为子进程运行。当 ssh 触发密码提示时,会启动一个孙子进程(即与 TUI 相同的二进制文件),该进程会连接回主 TUI 进程。TUI 向用户提示输入密码,并将其转发给孙子进程。最后,孙子进程将密码输出到标准输出,以满足 ssh 的要求。
以 jjui 为例,这是一个用于 Jujutsu VCS 的 TUI(它将操作委托给 `jj git`,而 `jj git` 可能会与 ssh 交互)。
劫持 ssh askpass 到 jjui 的拉取请求 (https://github.com/idursun/jjui/pull/423)
### 自我判断
与测试用例类似,可执行文件必须在启动时检测自己是作为主进程启动的还是作为后代进程启动的。使用环境变量是一个合理的选择,因为它们很容易传播到孙子进程。
在启动时,jjui 会检查 `JJUI_SSH_ASKPASS_ADDR`。如果此变量未设置,则说明我们是主进程,正常启动 TUI。
如果变量已设置,则说明我们是作为后代进程运行,并切换到“askpass”行为:
- 连接回主进程(参见下面的“家族通信”)。
- 将来自 ssh 的提示转发给 TUI(例如“输入 'ssh' 的 PIN:”)。
- 等待 TUI 返回密码。
- 将密码输出到标准输出。
- 退出(阻止正常的 TUI 启动)。
### 家族通信
后代进程必须能够连接回主进程。在 jjui 中,主进程监听一个 Unix 套接字。为了允许多个 jjui 实例同时运行,套接字路径包含主进程的 PID。主实例随后通过 `JJUI_SSH_ASKPASS_ADDR` 环境变量传递此套接字路径。
后代进程可以在启动后直接连接到该套接字。
然而,在 jjui 的情况下,要求用户输入密码是一个敏感操作。为了防止具有套接字访问权限的恶意进程触发提示,我添加了几个保护措施:
- 为每个子进程生成一个随机的 `JJUI_SSH_ASKPASS_KEY` 环境变量。后代进程连接到套接字时,必须发送此密钥。主进程验证该密钥是否关联到一个已知的子进程。
- 密钥检查成功后,主进程会向上遍历连接后代进程的祖先(PID)链。如果在链中找到与该密钥关联的子进程 PID,则触发提示。否则,断开连接。
维护子进程的 PID 和密钥并不简单,因为密钥必须在启动子进程之前生成。一旦子进程启动,我们就可以保存其 PID,但同时要考虑到孙子进程可能已经在此期间与主进程建立了连接(有关使用通道的 Go 示例,请参阅上面链接的拉取请求中的子进程结构)。
获取 Unix 套接字另一端进程的 PID 需要调用特定于操作系统的函数(例如,在 Go 中使用 `github.com/tailscale/peercred` 包)。
## 注意事项
虽然这种技术非常强大,但也存在一些缺点。
首先,调试起来可能相当麻烦。如果环境变量没有正确传递(或清除),很容易触发进程的无限递归。
此外,如果在 Go 测试中使用此技术并启用了竞态检测器,请注意默认情况下所有执行会在退出前休眠 1 秒(可能是为了检测延迟的竞态条件)。可以通过设置另一个环境变量来缓解:`GORACE="atexit_sleep_ms=0"`。
数据竞态检测器 - go.dev (https://go.dev/doc/articles/race_detector)
在 Forgejo 的集成测试中,可执行文件被各种 Git 钩子多次调用。竞态检测器的默认休眠导致集成测试超时,耗时超过 2 小时(而通常只需 45 分钟)。
自我执行 Forgejo 测试二进制文件的拉取请求 (https://codeberg.org/forgejo/forgejo/pulls/12855)
相似文章
什么是 BusyBox?
一篇解释性文章,详细介绍了 BusyBox 如何在 Alpine Linux 中作为多调用二进制文件发挥作用,通过符号链接和小程序配置为各种命令行工具提供单一可执行文件。
调试挂起的Go程序的技巧
一份实用指南,涵盖了调试挂起的Go程序的三种方法:使用SIGQUIT打印堆栈跟踪、附加delve调试器以及保存核心转储供后续分析。
Go 实验详解
本文介绍了 Go 语言中实验性功能的处理方式、生命周期以及近期实验示例。
在脚本的 shebang 行中使用 LLM
Simon Willison 演示了如何在脚本的 shebang 行中使用 llm CLI 工具,从而直接从可执行文件执行 LLM 提示词与工具调用。
我在我的 shell 中嵌入了一个 AI 代理。现在它可以运行交互式程序。
作者介绍了 'agent-sh',这是一个开源项目,将 AI 代理直接嵌入到 shell 中,以管理交互式程序和终端命令,无需手动复制粘贴。