























I call the "self-calling executable" an inception technique, by which a currently running executable starts another version of itself (directly or indirectly). This technique can be quite useful in testing scenarios as well as in command-line tools (such as TUIs).
Ideally, an executable is correctly structured to allow for isolated testing. However, it is quite easy (and common) to have a less-than-optimal architecture (such as relying on global state) which can interfere when multiple tests are running in parallel (or even sequentially).
To circumvent this issue in Go, we can start a subprocess. This subprocess is the currently running test executable itself, but with a specific environment variable set (e.g., TEST_MAIN).
In `TestMain(m *testing.M)` (a special Go test function), if this environment variable is detected, no standard tests are run. Instead, the main command is executed directly.
func TestMain(m *testing.M) {
switch os.Getenv("TEST_MAIN") {
case "":
os.Exit(m.Run()) // standard tests
default:
os.Exit(cmd.Main()) // run your main
}
}
// runMain can be called inside tests to run the executable as a black-box.
func runMain(args ...string) ([]byte, error) {
// os.Args[0] points to the current executable
cmd := exec.Command(os.Args[0], args...)
cmd.Env = append(os.Environ(),
"TEST_MAIN=main",
)
return cmd.Output()
}
For other variations of this technique, the following blog post is worth a read:
Mock programs to test os/exec in Go | Abhinav Gupta
Another use case for self-calling executables is interacting with third-party tools. For example, the ssh command considers the SSH_ASKPASS environment variable to locate an executable to run when the user needs to be prompted for a password (like an SSH-Key passphrase or a TPM PIN).
When a TUI needs to perform an SSH operation, it can set the SSH_ASKPASS environment variable to point to itself and run ssh as a child process. When ssh triggers a password prompt, a grand-child process is started (the same binary as the TUI), which connects back to the main TUI process. The TUI prompts the user for the password and forwards it to the grand-child process. Finally, the grand-child outputs the password to stdout, as expected by ssh.
Let's consider jjui as an example; a TUI for the Jujutsu VCS (which delegates operations to `jj git`, which may interact with ssh).
Pull Request to hijack ssh askpass to jjui
Just like in the test case, the executable must detect at startup whether it was launched as the main process or as a descendant. Using environment variables is a sensible choice since they easily propagate to grand-children.
On startup, jjui checks for JJUI_SSH_ASKPASS_ADDR. If this variable is not set, we are the main process and start the TUI normally.
If the variable is set, we are running as a descendant and switch to the "askpass" behavior:
Descendants must be able to connect back to the main process. In jjui, the main process listens on a Unix socket. To allow multiple instances of jjui to run at the same time, the socket path includes the main process's PID. The main instance then passes this socket path down via the JJUI_SSH_ASKPASS_ADDR environment variable.
The descendant can then directly connect to this socket upon startup.
In the case of jjui, however, asking the user for a password is a sensitive operation. To prevent malicious processes with socket access from triggering a prompt, I added a couple of safeguards:
Bookkeeping the subprocess PIDs and keys is not trivial, since the key must be generated before launching the subprocess. Once the subprocess is started, we can then save its PID while accounting for the fact, that the grand-child might have already dialed the main process in the meantime (see the subprocess struct in the pull request linked above for a Go example, using channels).
Getting the PID of the process on the other side of the Unix socket is done via OS-specific calls (e.g., in Go using the github.com/tailscale/peercred package).
While this technique is quite powerful, it is not without drawbacks.
First, it can be quite cumbersome to debug. If environment variables are not correctly passed around (or cleared), you can easily trigger an infinite recursion of processes.
Moreover, if you are using this technique in Go tests with the race detector enabled, note that by default all executions will sleep for 1 second before exiting (presumably to detect late race conditions). This can be mitigated by setting yet another environment variable: GORACE="atexit_sleep_ms=0".
In Forgejo's integration tests, the executable is called many times by various Git hooks. The race detector's default sleep caused the integration tests to time out, after more than 2 hours (instead of the usual 45 minutes).
Do you have a comment or want to share some feedback?
Feel free to
contact me
Back to the index View on Gemini [?]
Unless otherwise noted, site content by
Olivier, licensed under
CC BY-SA 4.0.
Source code
(mainly AGPL-3.0-or-later).
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。